Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
13 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "abowloflrf/apid", "files": {"config.example.toml": {"1705ab747e64897248e1baef0a982d94f27f5c47": {"t": 1786067832.0425885, "n": 4452, "h": "e8a4c0480da02e8d", "c": "# apid forwarding config example.\n# Copy to config.toml (or pass --config <path>) and edit.\n# Ops params (listen addr / SQLite / TRACE) stay in env vars; see CLAUDE.md.\n#\n# Two tables:\n# [[upstream]] — a backend, defined once and referenced by name.\n# [[route]] — a public entrypoint (path + client protocol) that dispatches\n# by the request's model to one upstream.\n#\n# Conversion happens when a route's input_protocol differs from the matched\n# upstream's protocol (currently only openai_responses -> openai_chat_completions).\n# Equal protocols = pure forward (bytes passed through, metrics still collected).\n# An upstream with protocol = \"openai_chat_completions\" and\n# supports_responses = true also serves OpenAI Responses natively: responses\n# routes hitting it forward raw bytes to its responses endpoint instead of\n# converting — no duplicate upstream needed for the same provider.\n# Protocols: openai_responses, openai_chat_completions, anthropic_messages.\n#\n# Optional inbound client auth for apid itself. Empty/omitted = disabled.\n# When set, clients must present this key as Authorization: Bearer <key>,\n# X-Api-Key: <key>, or Api-Key: <key>. This key is never forwarded upstream.\n# Only forwarding routes require it; /healthz and /stats(/*) stay open.\n# client_api_key = \"apid-local-static-key\"\n\n# ── Upstreams: write each backend once, reuse from any entrypoint. ──\n[[upstream]]\nname = \"deepseek\"\nprotocol = \"openai_chat_completions\"\nbase_url = \"https://api.deepseek.com/v1\"\npath = \"/chat/completions\"\napi_key = \"sk-xxx\" # empty = pass through the client's Authorization\nmodel = \"deepseek-chat\" # empty = pass through the client's model\n\n[[upstream]]\nname = \"openai\"\nprotocol = \"openai_chat_completions\"\nbase_url = \"https://api.openai.com\"\npath = \"/v1/chat/completions\"\nsupports_responses = true # same backend also speaks Responses natively\n# responses_path = \"/v1/responses\" # optional; empty = derived from path\n# # (strip \"/chat/completions\", append \"/responses\")\n\n[[upstream]]\nname = \"anthropic\"\nprotocol = \"anthropic_messages\"\nbase_url = \"https://api.anthropic.com\"\npath = \"/v1/messages\"\napi_key = \"sk-ant-xxx\" # sent as X-Api-Key for anthropic_messages\n\n# ── Responses entrypoint: clients speak Responses API, dispatched by model. ──\n[[route]]\npath = \"/v1/responses\"\ninput_protocol = \"openai_responses\"\n\n [[route.model]]\n match = \"deepseek-*\" # exact name or glob\n upstream = \"deepseek\" # convert (responses -> chat) since protocols differ\n # model omitted => inherit upstream.model (\"deepseek-chat\")\n\n [[route.model]]\n match = \"gpt-4o\" # rewrite this incoming model for the same upstream\n upstream = \"deepseek\"\n model = \"deepseek-reasoner\" # per-rule override beats upstream.model\n\n [[route.model]]\n match = \"*\" # empty or \"*\" = catch-all (at most one)\n upstream = \"openai\" # supports_responses = true => native responses -> responses\n # passthrough (no conversion), forwarded to /v1/responses\n model = \"\" # \"\" = force pass-through the client's model\n # (omit the key instead to inherit upstream.model)\n\n# ── Chat entrypoint: same upstreams reused, no duplication of backend info. ──\n[[route]]\npath = \"/v1/chat/completions\"\ninput_protocol = \"openai_chat_completions\"\n\n [[route.model]]\n upstream = \"openai\" # equal protocols => pure forward\n\n# ── Anthropic Messages entrypoint: pure forward only, no protocol conversion. ──\n[[route]]\npath = \"/v1/messages\"\ninput_protocol = \"anthropic_messages\"\n\n [[route.model]]\n match = \"claude-*\"\n upstream = \"anthropic\" # equal protocols => pure forward\n\n# ── Standalone web search (optional) ──────────────────────────────────────────\n# When [search] is configured, apid serves POST /v1/alpha/search, the endpoint\n# Codex calls when a custom provider opts into supports_standalone_web_search.\n# This lets Codex's built-in web.run tool work with non-OpenAI backends.\n#\n# Enable on the Codex side by adding to ~/.codex/config.toml:\n# [model_providers.apid]\n# supports_standalone_web_search = true\n#\n# [search]\n# provider = \"exa\" # only \"exa\" supported\n# api_key = \"your-exa-api-key\" # Exa API key (https://exa.ai)\n# # base_url = \"https://api.exa.ai\" # optional; defaults to this\n# # path = \"/v1/alpha/search\" # optional; defaults to this\n"}}, "server/topology_test.go": {"78fe236bdc5b0f2f09211105055787ec25565245": {"t": 1786068476.6464996, "n": 7088, "h": "e5dbdbce9c626335", "c": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/abowloflrf/apid/config\"\n\t\"github.com/abowloflrf/apid/store\"\n)\n\nfunc ptr(s string) *string { return &s }\n\nfunc topologyConfig() config.Config {\n\treturn config.Config{\n\t\tListen: \":19092\",\n\t\tUpstreams: []config.Upstream{\n\t\t\t{Name: \"deepseek\", Protocol: config.ProtoChat, BaseURL: \"https://api.deepseek.com/v1\", Path: \"/chat/completions\", APIKey: \"sk-secret-chat\", Model: \"deepseek-chat\"},\n\t\t\t{Name: \"anthropic\", Protocol: config.ProtoAnthropic, BaseURL: \"https://api.anthropic.com\", Path: \"/v1/messages\", APIKey: \"sk-secret-ant\"},\n\t\t},\n\t\tRoutes: []config.Route{\n\t\t\t{Path: \"/v1/responses\", InputProtocol: config.ProtoResponses, Models: []config.ModelRule{\n\t\t\t\t{Match: \"gpt-4o\", Upstream: \"deepseek\", Model: ptr(\"deepseek-reasoner\")},\n\t\t\t\t{Match: \"ds-*\", Upstream: \"deepseek\"},\n\t\t\t\t{Match: \"*\", Upstream: \"deepseek\", Model: ptr(\"\")},\n\t\t\t}},\n\t\t\t{Path: \"/v1/messages\", InputProtocol: config.ProtoAnthropic, Models: []config.ModelRule{\n\t\t\t\t{Match: \"claude-*\", Upstream: \"anthropic\"},\n\t\t\t}},\n\t\t},\n\t}\n}\n\nfunc TestTopology(t *testing.T) {\n\ttop := New(topologyConfig(), &store.Store{}, nil).topology()\n\n\tif len(top.Routes) != 2 || len(top.Upstreams) != 2 {\n\t\tt.Fatalf(\"got %d routes / %d upstreams, want 2 / 2\", len(top.Routes), len(top.Upstreams))\n\t}\n\tif top.Storage || top.ClientAuth || top.Search != nil {\n\t\tt.Errorf(\"storage/auth/search should be off: %+v\", top)\n\t}\n\n\trules := top.Routes[0].Rules\n\tif len(rules) != 3 {\n\t\tt.Fatalf(\"got %d rules, want 3\", len(rules))\n\t}\n\t// responses -> chat is a conversion; the per-rule model wins over the upstream's.\n\tif rules[0].Mode != \"convert\" || rules[0].MatchKind != \"exact\" ||\n\t\trules[0].ModelSource != \"rule\" || rules[0].EffectiveModel != \"deepseek-reasoner\" {\n\t\tt.Errorf(\"exact rule = %+v\", rules[0])\n\t}\n\t// Key omitted => inherit the upstream's model.\n\tif rules[1].MatchKind != \"glob\" || rules[1].ModelSource != \"upstream\" || rules[1].EffectiveModel != \"deepseek-chat\" {\n\t\tt.Errorf(\"glob rule = %+v\", rules[1])\n\t}\n\t// model = \"\" forces the client's model through even though the upstream sets one.\n\tif rules[2].MatchKind != \"catchall\" || rules[2].ModelSource != \"passthrough\" || rules[2].EffectiveModel != \"\" {\n\t\tt.Errorf(\"catch-all rule = %+v\", rules[2])\n\t}\n\tif r := top.Routes[1].Rules[0]; r.Mode != \"forward\" {\n\t\tt.Errorf(\"same-protocol route mode = %q, want forward\", r.Mode)\n\t}\n\n\tds := top.Upstreams[0]\n\tif ds.RefCount != 3 || ds.Endpoint != \"https://api.deepseek.com/v1/chat/completions\" {\n\t\tt.Errorf(\"deepseek upstream = %+v\", ds)\n\t}\n\tif ds.Auth != \"api_key\" || ds.AuthHdr != \"Authorization: Bearer\" {\n\t\tt.Errorf(\"chat auth = %q / %q\", ds.Auth, ds.AuthHdr)\n\t}\n\tif ant := top.Upstreams[1]; ant.AuthHdr != \"X-Api-Key\" {\n\t\tt.Errorf(\"anthropic auth header = %q, want X-Api-Key\", ant.AuthHdr)\n\t}\n}\n\n// TestTopologyDualProtocol: supports_responses 的 upstream 在 responses 入口下\n// 显示为 raw forward(via_responses),端点换成 responses 端点。\nfunc TestTopologyDualProtocol(t *testing.T) {\n\tcfg := config.Config{\n\t\tUpstreams: []config.Upstream{{\n\t\t\tName: \"up\",\n\t\t\tProtocol: config.ProtoChat,\n\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\tPath: \"/v1/chat/completions\",\n\t\t\tSupportsResponses: true,\n\t\t}},\n\t\tRoutes: []config.Route{{\n\t\t\tPath: \"/v1/responses\",\n\t\t\tInputProtocol: config.ProtoResponses,\n\t\t\tModels: []config.ModelRule{{Upstream: \"up\"}},\n\t\t}},\n\t}\n\ttop := New(cfg, &store.Store{}, nil).topology()\n\n\tr := top.Routes[0].Rules[0]\n\tif r.Mode != \"forward\" || !r.ViaResponses || r.UpstreamProto != string(config.ProtoResponses) {\n\t\tt.Errorf(\"rule = %+v, want forward via responses with wire protocol openai_responses\", r)\n\t}\n\tif r.Endpoint != \"https://api.example.com/v1/responses\" {\n\t\tt.Errorf(\"rule endpoint = %q, want responses endpoint\", r.Endpoint)\n\t}\n\n\tu := top.Upstreams[0]\n\tif !u.SupportsResponses || u.ResponsesEndpoint != \"https://api.example.com/v1/responses\" {\n\t\tt.Errorf(\"upstream = %+v, want supports_responses with responses endpoint\", u)\n\t}\n}\n\n// Upstream keys must never reach the browser; only the mode they imply.\nfunc TestTopologyHidesSecrets(t *testing.T) {\n\tcfg := topologyConfig()\n\tcfg.ClientAPIKey = \"apid-inbound-key\"\n\tcfg.Search = config.SearchConfig{Provider: \"exa\", APIKey: \"exa-secret\"}\n\n\tw := httptest.NewRecorder()\n\tNew(cfg, &store.Store{}, nil).Handler().ServeHTTP(w, httptest.NewRequest(\"GET\", \"/stats/topology\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\tbody := w.Body.String()\n\tfor _, secret := range []string{\"sk-secret-chat\", \"sk-secret-ant\", \"apid-inbound-key\", \"exa-secret\"} {\n\t\tif strings.Contains(body, secret) {\n\t\t\tt.Errorf(\"response leaks %q: %s\", secret, body)\n\t\t}\n\t}\n\n\tvar top topologyResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &top); err != nil {\n\t\tt.Fatalf(\"decode: %v\", err)\n\t}\n\tif !top.ClientAuth || top.Search == nil || top.Search.Path != \"/v1/alpha/search\" {\n\t\tt.Errorf(\"client auth / search summary = %+v, %+v\", top.ClientAuth, top.Search)\n\t}\n}\n\n// Client auth strips inbound credentials before forwarding, so an upstream with\n// no key of its own ends up unauthenticated — the UI needs to be able to say so.\nfunc TestTopologyAuthStripped(t *testing.T) {\n\tcfg := config.Config{\n\t\tClientAPIKey: \"inbound\",\n\t\tUpstreams: []config.Upstream{{Name: \"u\", Protocol: config.ProtoChat, BaseURL: \"https://x\", Path: \"/v1/chat/completions\"}},\n\t\tRoutes: []config.Route{{Path: \"/v1/chat/completions\", InputProtocol: config.ProtoChat, Models: []config.ModelRule{{Upstream: \"u\"}}}},\n\t}\n\tif got := New(cfg, &store.Store{}, nil).topology().Upstreams[0].Auth; got != \"stripped\" {\n\t\tt.Errorf(\"auth = %q, want stripped\", got)\n\t}\n\tcfg.ClientAPIKey = \"\"\n\tif got := New(cfg, &store.Store{}, nil).topology().Upstreams[0].Auth; got != \"passthrough\" {\n\t\tt.Errorf(\"auth = %q, want passthrough\", got)\n\t}\n}\n\n// The config view reads config, not the DB, so it stays available with storage\n// off — unlike every other /stats/* endpoint.\nfunc TestTopologyWorksWithoutStorage(t *testing.T) {\n\tw := httptest.NewRecorder()\n\tNew(topologyConfig(), &store.Store{}, nil).Handler().ServeHTTP(w, httptest.NewRequest(\"GET\", \"/stats/topology\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200; body=%s\", w.Code, w.Body.String())\n\t}\n}\n\nfunc TestTopologyCodexSubscription(t *testing.T) {\n\tcfg := subscriptionConfig(\"local-key\", \"\")\n\ttop := New(cfg, &store.Store{}, nil).topology()\n\tu := top.Upstreams[0]\n\tif u.AuthMode != string(config.AuthModeCodexSubscription) ||\n\t\tu.Auth != string(config.AuthModeCodexSubscription) || !u.Experimental {\n\t\tt.Fatalf(\"subscription upstream = %+v\", u)\n\t}\n\tif u.Endpoint != \"https://chatgpt.com/backend-api/codex/responses\" ||\n\t\tu.AuthHdr != \"Authorization: Bearer (client credential)\" {\n\t\tt.Fatalf(\"subscription endpoint/auth = %+v\", u)\n\t}\n\tcompact := top.Routes[1]\n\tif compact.Operation != string(config.RouteOperationResponsesCompact) ||\n\t\tcompact.Rules[0].Endpoint != \"https://chatgpt.com/backend-api/codex/responses/compact\" {\n\t\tt.Fatalf(\"compact route = %+v\", compact)\n\t}\n}\n"}}}}