From 8e32c4de8ef6074cbeb039ade68b24beffd14616 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 5 Apr 2026 23:08:15 +0800 Subject: [PATCH 01/11] docs: refresh readme quick start and examples --- README.md | 75 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 35f09b2..b95e41c 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,56 @@ A lightweight Go proxy that exposes GitHub Copilot as OpenAI-compatible, Anthrop ## Quick Start +### Docker + ```bash -# Build from source (requires Go 1.26+) -go build -o copilot2api . +docker run -it --rm \ + -p 127.0.0.1:7777:7777 \ + -v ~/.config/copilot2api:/root/.config/copilot2api \ + ghcr.io/whtsky/copilot2api:latest +``` + +The volume mount persists your GitHub credentials across container restarts. The examples publish the port on `127.0.0.1` only so the proxy stays local by default. + +
+Docker Compose + +```yaml +services: + copilot2api: + image: ghcr.io/whtsky/copilot2api:latest + ports: + - "127.0.0.1:7777:7777" + volumes: + - ${HOME}/.config/copilot2api:/root/.config/copilot2api +``` + +Start it with: + +```bash +docker compose up +``` + +
+ +### Download a release binary + +```bash +# Example: macOS Apple Silicon +curl -L -o copilot2api \ + https://github.com/whtsky/copilot2api/releases/latest/download/copilot2api-darwin-arm64 + +# Example: Linux x64 +# curl -L -o copilot2api \ +# https://github.com/whtsky/copilot2api/releases/latest/download/copilot2api-linux-amd64 -# Start the proxy +chmod +x copilot2api ./copilot2api ``` -First run will prompt GitHub Device Flow authentication: +Download the asset that matches your platform from [GitHub Releases](https://github.com/whtsky/copilot2api/releases/latest). Published binaries use names like `copilot2api-linux-amd64`, `copilot2api-linux-arm64`, `copilot2api-darwin-amd64`, `copilot2api-darwin-arm64`, `copilot2api-windows-amd64.exe`, and `copilot2api-windows-arm64.exe`. + +On first run, both Docker and downloaded binaries prompt GitHub Device Flow authentication: ``` πŸ” GitHub Authentication Required @@ -118,7 +159,8 @@ Or add to `~/.config/amp/settings.json`: Chat completions, tool calls, and image input all route through Copilot API. Login and management routes (threads, telemetry) are proxied to `ampcode.com` β€” a free amp account is required for authentication. -## Usage with curl +
+Usage with curl ```bash # OpenAI chat completion @@ -139,6 +181,8 @@ curl http://localhost:7777/v1/models curl http://localhost:7777/usage ``` +
+
Usage with SDKs @@ -223,27 +267,6 @@ Environment variables are used as defaults when flags are not provided: CLI flags take precedence over environment variables. -## Docker - -```bash -docker run -it -p 7777:7777 \ - -v ~/.config/copilot2api:/root/.config/copilot2api \ - ghcr.io/whtsky/copilot2api -``` - -The Docker image defaults to `COPILOT2API_HOST=0.0.0.0` so port forwarding works out of the box. The volume mount persists your GitHub credentials across container restarts. First run will prompt Device Flow authentication. - -To use a custom port: - -```bash -docker run -it -p 8080:8080 \ - -v ~/.config/copilot2api:/root/.config/copilot2api \ - -e COPILOT2API_PORT=8080 \ - ghcr.io/whtsky/copilot2api -``` - -> ⚠️ The Docker image listens on all interfaces by default. Only publish the port to `127.0.0.1` (e.g. `-p 127.0.0.1:7777:7777`) unless you know what you're doing. - ## How It Works 1. Authenticates with GitHub via Device Flow OAuth From 9e77bcc9bd3f373807ed5e44adea4784d94a2866 Mon Sep 17 00:00:00 2001 From: Wu Haotian Date: Thu, 16 Apr 2026 19:29:36 +0800 Subject: [PATCH 02/11] fix: keep anthropic thinking signatures on the same block (#5) Attach reasoning signatures to the currently open thinking block before closing it, instead of emitting a separate thinking block. Also preserve reasoning_opaque on finish and add regression tests for thinking->finish and thinking->tool-call flows. --- anthropic/stream.go | 71 +++++++++++++++++----- anthropic/stream_test.go | 124 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/anthropic/stream.go b/anthropic/stream.go index b22bb4d..af97894 100644 --- a/anthropic/stream.go +++ b/anthropic/stream.go @@ -154,9 +154,11 @@ func handleContent(delta OpenAIMessage, state *StreamState) []AnthropicStreamEve content = *delta.Content.Text } - // Close thinking block if open + signature := reasoningOpaque(delta) + + // Close thinking block if open, attaching signature to the same block. if state.ThinkingBlockOpen { - events = append(events, closeThinkingBlock(state)...) + events = append(events, closeThinkingBlock(state, signature)...) } // Close tool block if open @@ -198,9 +200,13 @@ func handleContent(delta OpenAIMessage, state *StreamState) []AnthropicStreamEve func handleToolCalls(delta OpenAIMessage, state *StreamState) []AnthropicStreamEvent { var events []AnthropicStreamEvent - // Close thinking block if open + signature := reasoningOpaque(delta) + consumedThinkingSignature := false + + // Close thinking block if open, attaching signature to the same block. if state.ThinkingBlockOpen { - events = append(events, closeThinkingBlock(state)...) + events = append(events, closeThinkingBlock(state, signature)...) + consumedThinkingSignature = signature != "" } // Handle reasoning opaque in tool calls @@ -213,9 +219,11 @@ func handleToolCalls(delta OpenAIMessage, state *StreamState) []AnthropicStreamE state.ContentBlockOpen = false } - // Handle reasoning opaque - if delta.ReasoningOpaque != nil && *delta.ReasoningOpaque != "" { - events = append(events, handleReasoningOpaque(delta, state)...) + // Handle reasoning opaque only when there was no open thinking block to attach it to. + if !consumedThinkingSignature && !state.ThinkingBlockOpen { + if signature != "" { + events = append(events, handleReasoningOpaque(signature, state)...) + } } for _, toolCall := range delta.ToolCalls { @@ -286,9 +294,13 @@ func handleToolCalls(delta OpenAIMessage, state *StreamState) []AnthropicStreamE func handleFinish(choice OpenAIChunkChoice, chunk OpenAIChatCompletionChunk, state *StreamState) []AnthropicStreamEvent { var events []AnthropicStreamEvent - // Close thinking block if open + signature := reasoningOpaque(choice.Delta) + consumedThinkingSignature := false + + // Close thinking block if open, attaching signature to the same block. if state.ThinkingBlockOpen { - events = append(events, closeThinkingBlock(state)...) + events = append(events, closeThinkingBlock(state, signature)...) + consumedThinkingSignature = signature != "" } // Close any open content block @@ -304,9 +316,11 @@ func handleFinish(choice OpenAIChunkChoice, chunk OpenAIChatCompletionChunk, sta state.ContentBlockOpen = false state.ContentBlockIndex++ - // Handle reasoning opaque for non-tool blocks - if !toolBlockOpen { - events = append(events, handleReasoningOpaque(choice.Delta, state)...) + // Handle reasoning opaque for non-tool blocks when there was no open thinking block. + if !toolBlockOpen && !consumedThinkingSignature { + if signature != "" { + events = append(events, handleReasoningOpaque(signature, state)...) + } } } @@ -344,10 +358,10 @@ func handleFinish(choice OpenAIChunkChoice, chunk OpenAIChatCompletionChunk, sta return events } -func handleReasoningOpaque(delta OpenAIMessage, state *StreamState) []AnthropicStreamEvent { +func handleReasoningOpaque(signature string, state *StreamState) []AnthropicStreamEvent { var events []AnthropicStreamEvent - if delta.ReasoningOpaque != nil && *delta.ReasoningOpaque != "" { + if signature != "" { events = append(events, AnthropicStreamEvent{ Type: "content_block_start", @@ -357,12 +371,20 @@ func handleReasoningOpaque(delta OpenAIMessage, state *StreamState) []AnthropicS Thinking: "", }, }, + AnthropicStreamEvent{ + Type: "content_block_delta", + Index: intPtr(state.ContentBlockIndex), + Delta: &AnthropicContentDelta{ + Type: "thinking_delta", + Thinking: "", + }, + }, AnthropicStreamEvent{ Type: "content_block_delta", Index: intPtr(state.ContentBlockIndex), Delta: &AnthropicContentDelta{ Type: "signature_delta", - Signature: *delta.ReasoningOpaque, + Signature: signature, }, }, AnthropicStreamEvent{ @@ -376,10 +398,20 @@ func handleReasoningOpaque(delta OpenAIMessage, state *StreamState) []AnthropicS return events } -func closeThinkingBlock(state *StreamState) []AnthropicStreamEvent { +func closeThinkingBlock(state *StreamState, signature string) []AnthropicStreamEvent { var events []AnthropicStreamEvent if state.ThinkingBlockOpen { + if signature != "" { + events = append(events, AnthropicStreamEvent{ + Type: "content_block_delta", + Index: intPtr(state.ContentBlockIndex), + Delta: &AnthropicContentDelta{ + Type: "signature_delta", + Signature: signature, + }, + }) + } events = append(events, AnthropicStreamEvent{ Type: "content_block_stop", @@ -393,6 +425,13 @@ func closeThinkingBlock(state *StreamState) []AnthropicStreamEvent { return events } +func reasoningOpaque(delta OpenAIMessage) string { + if delta.ReasoningOpaque != nil { + return *delta.ReasoningOpaque + } + return "" +} + func isToolBlockOpen(state *StreamState) bool { if !state.ContentBlockOpen { return false diff --git a/anthropic/stream_test.go b/anthropic/stream_test.go index 97aa28b..ae15927 100644 --- a/anthropic/stream_test.go +++ b/anthropic/stream_test.go @@ -361,6 +361,130 @@ func TestConvertOpenAIChunkToAnthropicEvents_Finish(t *testing.T) { } } +func TestConvertOpenAIChunkToAnthropicEvents_ThinkingFinishWithSignature(t *testing.T) { + state := NewStreamState() + state.MessageStartSent = true + + thinkingChunk := OpenAIChatCompletionChunk{ + ID: "msg_123", + Model: "claude-3-sonnet-20240229", + Choices: []OpenAIChunkChoice{{ + Index: 0, + Delta: OpenAIMessage{ + ReasoningText: stringPtr("Let me think about this..."), + }, + }}, + } + if _, err := ConvertOpenAIChunkToAnthropicEvents(thinkingChunk, state); err != nil { + t.Fatalf("Thinking conversion failed: %v", err) + } + + finishChunk := OpenAIChatCompletionChunk{ + ID: "msg_123", + Model: "claude-3-sonnet-20240229", + Choices: []OpenAIChunkChoice{{ + Index: 0, + Delta: OpenAIMessage{ + ReasoningOpaque: stringPtr("sig_123"), + }, + FinishReason: "stop", + }}, + Usage: &OpenAIUsage{PromptTokens: 10, CompletionTokens: 25}, + } + + events, err := ConvertOpenAIChunkToAnthropicEvents(finishChunk, state) + if err != nil { + t.Fatalf("Finish conversion failed: %v", err) + } + + if len(events) != 4 { + t.Fatalf("Expected 4 events (signature + stop + delta + stop), got %d", len(events)) + } + + cd := contentDelta(t, events[0]) + if cd.Type != "signature_delta" { + t.Fatalf("Expected first delta to be signature_delta, got %q", cd.Type) + } + if cd.Signature != "sig_123" { + t.Fatalf("Expected signature sig_123, got %q", cd.Signature) + } + + if events[1].Type != "content_block_stop" { + t.Fatalf("Expected second event content_block_stop, got %q", events[1].Type) + } + if events[2].Type != "message_delta" { + t.Fatalf("Expected third event message_delta, got %q", events[2].Type) + } + if events[3].Type != "message_stop" { + t.Fatalf("Expected fourth event message_stop, got %q", events[3].Type) + } +} + +func TestConvertOpenAIChunkToAnthropicEvents_ThinkingToToolCallWithSignature(t *testing.T) { + state := NewStreamState() + state.MessageStartSent = true + + thinkingChunk := OpenAIChatCompletionChunk{ + ID: "msg_123", + Model: "claude-3-sonnet-20240229", + Choices: []OpenAIChunkChoice{{ + Index: 0, + Delta: OpenAIMessage{ + ReasoningText: stringPtr("Analyzing..."), + }, + }}, + } + if _, err := ConvertOpenAIChunkToAnthropicEvents(thinkingChunk, state); err != nil { + t.Fatalf("Thinking conversion failed: %v", err) + } + + toolChunk := OpenAIChatCompletionChunk{ + ID: "msg_123", + Model: "claude-3-sonnet-20240229", + Choices: []OpenAIChunkChoice{{ + Index: 0, + Delta: OpenAIMessage{ + ReasoningOpaque: stringPtr("sig_456"), + ToolCalls: []OpenAIToolCall{{ + Index: intPtr(0), + ID: "call_1", + Function: OpenAIToolCallFunction{ + Name: "search", + }, + }}, + }, + }}, + } + + events, err := ConvertOpenAIChunkToAnthropicEvents(toolChunk, state) + if err != nil { + t.Fatalf("Tool conversion failed: %v", err) + } + + if len(events) != 3 { + t.Fatalf("Expected 3 events (signature + stop + tool_start), got %d", len(events)) + } + + cd := contentDelta(t, events[0]) + if cd.Type != "signature_delta" { + t.Fatalf("Expected first delta to be signature_delta, got %q", cd.Type) + } + if cd.Signature != "sig_456" { + t.Fatalf("Expected signature sig_456, got %q", cd.Signature) + } + + if events[1].Type != "content_block_stop" { + t.Fatalf("Expected second event content_block_stop, got %q", events[1].Type) + } + if events[2].Type != "content_block_start" || events[2].ContentBlock == nil || events[2].ContentBlock.Type != "tool_use" { + t.Fatalf("Expected third event tool_use content_block_start, got %#v", events[2]) + } + + if events[2].Index == nil || *events[2].Index != 1 { + t.Fatalf("Expected tool block index 1, got %v", events[2].Index) + } +} + func TestConvertOpenAIChunkToAnthropicEvents_ComplexFlow(t *testing.T) { state := NewStreamState() From 7368d6b74d91b8a8365d88b6705995bc2f1289c0 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 16:27:37 +0800 Subject: [PATCH 03/11] chore: release v0.3.1 Co-Authored-By: Claude Opus 4 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac46bf6..f6f65d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ ## [Unreleased] +## [0.3.1] - 2026-04-26 + ### Bug Fixes +- Fix Anthropic thinking signatures being emitted as a separate block instead of attached to the currently open thinking block - Fix Docker image crash (`exec /copilot2api: no such file or directory`) caused by dynamically-linked binary in `scratch` image β€” add `CGO_ENABLED=0` to CI cross-compilation - Fix Docker multi-arch build: arm64 image was shipping the amd64 binary due to `ARG TARGETARCH=amd64` default overriding buildx's automatic platform arg - Fix CI triggering redundant runs on tag pushes β€” `on: push` now scoped to `main` branch only @@ -12,6 +15,10 @@ - Add Docker smoke test β€” `docker run --version` gate before pushing to prevent broken images from reaching the registry +### Docs + +- Refresh README quick start and examples + ## [0.3.0] - 2026-04-03 ### Features From 6f3258266a23dbc35488bd2a3ec15b9e53b1fa0f Mon Sep 17 00:00:00 2001 From: Wu Haotian Date: Thu, 25 Jun 2026 15:46:19 +0800 Subject: [PATCH 04/11] feat: add amp search and page extraction endpoints (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add amp search and page extraction endpoints Handle webSearch2 locally via Copilot Responses API with web_search tool (gpt-5-mini), and extractWebPageContent via Jina Reader, so amp CLI web search works without a paid ampcode.com account. Co-Authored-By: Claude Opus 4 * feat: auto-upgrade models to best available variant When the upstream model list contains a better variant (e.g. claude-opus-4.7-1m-internal), automatically use it instead of the base model. This enables features like effort: high that are only supported by extended variants. Co-Authored-By: Claude Opus 4 * refactor: replace context-1m header detection with model auto-upgrade The auto-upgrade logic already promotes models to -1m/-1m-internal variants, making the explicit anthropic-beta header detection redundant. Add COPILOT2API_NO_MODEL_UPGRADE env var to opt out of auto-upgrade. Update README to document auto-upgrade behavior. Co-Authored-By: Claude Opus 4 * fix: native passthrough uses upgraded model instead of alias-resolved model The normalizeNativeMessagesBody was receiving the alias-resolved model (e.g. claude-opus-4.7) instead of the auto-upgraded model (e.g. claude-opus-4.7-1m-internal), causing upstream to reject requests with unsupported effort/thinking values for the non-upgraded model. Also adds debug logging for upstream request/response bodies. * perf: guard upstream request debug log with level check * refactor: pass debug flag to upstream client, avoid per-request level check * feat: add amp local mode β€” serve thread management APIs locally Amp local mode is always enabled. Thread management (upload, get, list, search, delete, meta merge), telemetry, user info, and other management APIs are served from local ~/.local/share/amp/threads/ instead of proxying to ampcode.com. Unhandled /api/ routes still fall through to the ampcode.com reverse proxy. Endpoints: - GET /api/threads/find β€” full-text search across local threads - GET /api/threads/{id}.md β€” render thread as markdown - POST /api/internal β€” uploadThread, setThreadMeta, deleteThread, getThread, listThreads, getUserInfo, and 15+ other method stubs - POST /api/telemetry β€” no-op - POST /api/durable-thread-workers/{id} β€” stub - GET /api/users/{id}, /api/attachments, /news.rss β€” stubs Supports gzip-compressed request bodies and params-wrapped payloads. --------- Co-authored-by: Claude Opus 4 --- CHANGELOG.md | 5 + README.md | 12 +- amplocal/amplocal_test.go | 374 ++++++++++++++++++++++++ amplocal/handler.go | 569 ++++++++++++++++++++++++++++++++++++ amplocal/state.go | 324 ++++++++++++++++++++ amplocal/types.go | 44 +++ ampsearch/handler.go | 148 ++++++++++ ampsearch/model.go | 213 ++++++++++++++ anthropic/handler.go | 33 +-- anthropic/models.go | 34 ++- anthropic/models_test.go | 29 ++ gemini/handler.go | 4 +- gemini/handler_test.go | 6 +- internal/upstream/client.go | 28 +- main.go | 50 +++- proxy/handler.go | 4 +- proxy/handler_test.go | 8 +- proxy/smart_routing_test.go | 20 +- 18 files changed, 1849 insertions(+), 56 deletions(-) create mode 100644 amplocal/amplocal_test.go create mode 100644 amplocal/handler.go create mode 100644 amplocal/state.go create mode 100644 amplocal/types.go create mode 100644 ampsearch/handler.go create mode 100644 ampsearch/model.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f65d4..c537502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Features + +- Add local amp search support (`webSearch2`) using the Copilot Responses API with `web_search` tool (`gpt-5-mini` by default), and page extraction (`extractWebPageContent`) via Jina Reader, so amp CLI web search works without a paid ampcode.com account +- Auto-upgrade models to the best available variant (e.g. `claude-opus-4.7` β†’ `claude-opus-4.7-1m-internal`) based on upstream model list, enabling features like `effort: high` that require extended variants + ## [0.3.1] - 2026-04-26 ### Bug Fixes diff --git a/README.md b/README.md index b95e41c..2239ef7 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,11 @@ Add to `~/.claude/settings.json`: } ``` -### 1M Context Window +### Model Auto-Upgrade -copilot2api supports Claude 1M context models. When Claude Code sends the `anthropic-beta: context-1m-...` header, the proxy automatically appends `-1m` to the model ID (e.g. `claude-opus-4.6` β†’ `claude-opus-4.6-1m`) so Copilot routes to the 1M variant. +copilot2api automatically upgrades models to the best available variant in the upstream model list. For example, if `claude-opus-4.7-1m-internal` is available, a request for `claude-opus-4.7` will automatically use it. This enables features like 1M context windows and `effort: high` that are only supported by extended variants. -To use it, select the 1M model variant in Claude Code via the `/model` command (e.g. `Opus (1M)`). Without this, Claude Code defaults to the standard 200K context window. +The upgrade priority is: `-1m-internal` > `-1m` > base model. ## Usage with Codex @@ -159,6 +159,8 @@ Or add to `~/.config/amp/settings.json`: Chat completions, tool calls, and image input all route through Copilot API. Login and management routes (threads, telemetry) are proxied to `ampcode.com` β€” a free amp account is required for authentication. +Web search (`webSearch2`) is handled locally via the Copilot Responses API with `web_search` tool (using `gpt-5-mini` by default). Page extraction (`extractWebPageContent`) uses [Jina Reader](https://jina.ai/reader/) β€” set `JINA_API_KEY` for higher rate limits (optional). No paid ampcode.com account needed. +
Usage with curl @@ -237,6 +239,8 @@ message = client.messages.create( | `/amp/v1/chat/completions` | POST | AmpCode chat completions (via Copilot API) | | `/amp/v1/models` | GET | AmpCode model listing | | `/api/provider/*` | POST | AmpCode provider-specific routes | +| `/api/internal?webSearch2` | POST | AmpCode web search (via Copilot Responses API) | +| `/api/internal?extractWebPageContent` | POST | AmpCode page extraction (via Jina Reader) | | `/api/*` | ANY | AmpCode management proxy to ampcode.com | | `/usage` | GET | Copilot usage and quota info | @@ -264,6 +268,8 @@ Environment variables are used as defaults when flags are not provided: | `COPILOT2API_PORT` | Server port | `7777` | | `COPILOT2API_TOKEN_DIR` | Token storage directory | `~/.config/copilot2api` | | `COPILOT2API_DEBUG` | Enable debug logging (`true`/`false`, `1`/`0`) | `false` | +| `COPILOT2API_NO_MODEL_UPGRADE` | Disable model auto-upgrade (`true`/`false`, `1`/`0`) | `false` | +| `JINA_API_KEY` | Jina API key for amp page extraction (optional) | — | CLI flags take precedence over environment variables. diff --git a/amplocal/amplocal_test.go b/amplocal/amplocal_test.go new file mode 100644 index 0000000..219615e --- /dev/null +++ b/amplocal/amplocal_test.go @@ -0,0 +1,374 @@ +package amplocal + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func ptr[T any](v T) *T { return &v } + +func setupTestState(t *testing.T) (*State, string) { + t.Helper() + dir := t.TempDir() + s := NewState(dir) + return s, dir +} + +func writeTestThread(t *testing.T, dir string, tf *ThreadFile) { + t.Helper() + data, err := json.Marshal(tf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, tf.ID+".json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestIndexAndSearch(t *testing.T) { + s, dir := setupTestState(t) + + writeTestThread(t, dir, &ThreadFile{ + ID: "t1", + Title: ptr("Hello World"), + Created: ptr(uint64(1000)), + Messages: []ThreadMessage{ + {Role: ptr("user"), Content: []ContentBlock{{Type: ptr("text"), Text: ptr("how are you")}}}, + {Role: ptr("assistant"), Content: []ContentBlock{{Type: ptr("text"), Text: ptr("I am fine")}}}, + }, + }) + writeTestThread(t, dir, &ThreadFile{ + ID: "t2", + Title: ptr("Golang Tips"), + Created: ptr(uint64(2000)), + Messages: []ThreadMessage{ + {Role: ptr("user"), Content: []ContentBlock{{Type: ptr("text"), Text: ptr("tell me about goroutines")}}}, + }, + }) + + // Search all + entries, hasMore := s.Search("", 50, 0) + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if hasMore { + t.Fatal("unexpected hasMore") + } + // Should be sorted by updatedAt desc + if entries[0].ID != "t2" { + t.Fatalf("expected t2 first, got %s", entries[0].ID) + } + + // Search by word + entries, _ = s.Search("golang", 50, 0) + if len(entries) != 1 || entries[0].ID != "t2" { + t.Fatalf("expected t2 for 'golang', got %v", entries) + } + + // Multi-word search (all must match) + entries, _ = s.Search("hello you", 50, 0) + if len(entries) != 1 || entries[0].ID != "t1" { + t.Fatalf("expected t1 for 'hello you', got %v", entries) + } + + // No match + entries, _ = s.Search("nonexistent", 50, 0) + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } + + // Pagination + entries, hasMore = s.Search("", 1, 0) + if len(entries) != 1 || !hasMore { + t.Fatalf("expected 1 entry with hasMore, got %d/%v", len(entries), hasMore) + } + entries, hasMore = s.Search("", 1, 1) + if len(entries) != 1 || hasMore { + t.Fatalf("expected 1 entry without hasMore, got %d/%v", len(entries), hasMore) + } +} + +func TestSearchWithPrefixFilters(t *testing.T) { + s, dir := setupTestState(t) + writeTestThread(t, dir, &ThreadFile{ + ID: "t1", + Title: ptr("Test Thread"), + Created: ptr(uint64(1000)), + }) + + // author: and file: filters are ignored, should still return results + entries, _ := s.Search("author:someone test", 50, 0) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } +} + +func TestShouldHandleLocally(t *testing.T) { + tests := []struct { + path string + expect bool + }{ + {"/api/internal", true}, + {"/api/telemetry", true}, + {"/api/threads/find", true}, + {"/api/threads/abc.md", true}, + {"/api/durable-thread-workers/xyz", true}, + {"/api/users/123", true}, + {"/api/attachments", true}, + {"/news.rss", true}, + {"/api/provider/openai/v1/chat/completions", false}, + {"/v1/models", false}, + {"/api/some-unknown", false}, + } + for _, tt := range tests { + if got := ShouldHandleLocally(tt.path); got != tt.expect { + t.Errorf("ShouldHandleLocally(%q) = %v, want %v", tt.path, got, tt.expect) + } + } +} + +func TestUploadThread(t *testing.T) { + s, _ := setupTestState(t) + h := NewHandler(s) + + tf := ThreadFile{ID: "upload-1", Title: ptr("Uploaded")} + body, _ := json.Marshal(tf) + + req := httptest.NewRequest("POST", "/api/internal?uploadThread", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.handleUploadThread(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify file exists + read, err := s.ReadThread("upload-1") + if err != nil { + t.Fatal(err) + } + if *read.Title != "Uploaded" { + t.Fatalf("expected title 'Uploaded', got %v", read.Title) + } +} + +func TestUploadThreadGzip(t *testing.T) { + s, _ := setupTestState(t) + h := NewHandler(s) + + tf := ThreadFile{ID: "gz-1", Title: ptr("Gzipped")} + body, _ := json.Marshal(tf) + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write(body) + gz.Close() + + req := httptest.NewRequest("POST", "/api/internal?uploadThread", &buf) + req.Header.Set("Content-Encoding", "gzip") + w := httptest.NewRecorder() + h.handleUploadThread(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + read, err := s.ReadThread("gz-1") + if err != nil { + t.Fatal(err) + } + if *read.Title != "Gzipped" { + t.Fatalf("expected title 'Gzipped', got %v", read.Title) + } +} + +func TestDeleteThread(t *testing.T) { + s, dir := setupTestState(t) + h := NewHandler(s) + + writeTestThread(t, dir, &ThreadFile{ID: "del-1", Title: ptr("Delete Me")}) + + body, _ := json.Marshal(map[string]string{"threadId": "del-1"}) + req := httptest.NewRequest("POST", "/api/internal?deleteThread", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.handleDeleteThread(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + _, err := s.ReadThread("del-1") + if !os.IsNotExist(err) { + t.Fatalf("expected file not found, got %v", err) + } +} + +func TestSetThreadMeta(t *testing.T) { + s, dir := setupTestState(t) + h := NewHandler(s) + + writeTestThread(t, dir, &ThreadFile{ + ID: "meta-1", + Meta: json.RawMessage(`{"existing": true}`), + }) + + body, _ := json.Marshal(map[string]any{ + "threadId": "meta-1", + "meta": map[string]any{"newField": "value"}, + }) + req := httptest.NewRequest("POST", "/api/internal?setThreadMeta", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.handleSetThreadMeta(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + read, _ := s.ReadThread("meta-1") + var meta map[string]any + json.Unmarshal(read.Meta, &meta) + if meta["existing"] != true || meta["newField"] != "value" { + t.Fatalf("unexpected meta: %v", meta) + } +} + +func TestThreadMarkdown(t *testing.T) { + s, dir := setupTestState(t) + h := NewHandler(s) + + writeTestThread(t, dir, &ThreadFile{ + ID: "md-1", + Title: ptr("Test MD"), + Messages: []ThreadMessage{ + {Role: ptr("user"), Content: []ContentBlock{{Type: ptr("text"), Text: ptr("hello")}}}, + {Role: ptr("assistant"), Content: []ContentBlock{ + {Type: ptr("tool_use"), Name: ptr("read_file"), Input: json.RawMessage(`{"path":"x"}`)}, + }}, + {Role: ptr("user"), Content: []ContentBlock{ + {Type: ptr("tool_result"), Content: json.RawMessage(`"file contents"`)}, + }}, + }, + }) + + req := httptest.NewRequest("GET", "/api/threads/md-1.md", nil) + w := httptest.NewRecorder() + h.ServeThreadMarkdown(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !bytes.Contains([]byte(body), []byte("# Test MD")) { + t.Fatal("missing title in markdown") + } + if !bytes.Contains([]byte(body), []byte("hello")) { + t.Fatal("missing message text") + } + + // Test with truncation + req = httptest.NewRequest("GET", "/api/threads/md-1.md?truncate_tool_results=1", nil) + w = httptest.NewRecorder() + h.ServeThreadMarkdown(w, req) + body = w.Body.String() + if !bytes.Contains([]byte(body), []byte("_(truncated)_")) { + t.Fatal("expected truncated tool result") + } +} + +func TestStubEndpoints(t *testing.T) { + s, _ := setupTestState(t) + h := NewHandler(s) + + // getUserInfo + req := httptest.NewRequest("POST", "/api/internal?getUserInfo", nil) + w := httptest.NewRecorder() + handled := h.TryServeInternal(w, req) + if !handled || w.Code != 200 { + t.Fatal("getUserInfo failed") + } + + // getThreadLabels + req = httptest.NewRequest("POST", "/api/internal?getThreadLabels", nil) + w = httptest.NewRecorder() + handled = h.TryServeInternal(w, req) + if !handled || w.Code != 200 { + t.Fatal("getThreadLabels failed") + } + + // telemetry + req = httptest.NewRequest("POST", "/api/telemetry", nil) + w = httptest.NewRecorder() + h.ServeTelemetry(w, req) + if w.Code != 200 { + t.Fatal("telemetry failed") + } + + // attachments + req = httptest.NewRequest("GET", "/api/attachments", nil) + w = httptest.NewRecorder() + h.ServeAttachments(w, req) + if w.Code != 200 { + t.Fatal("attachments failed") + } + + // users + req = httptest.NewRequest("GET", "/api/users/123", nil) + w = httptest.NewRecorder() + h.ServeUsers(w, req) + if w.Code != 200 { + t.Fatal("users failed") + } + + // news.rss + req = httptest.NewRequest("GET", "/news.rss", nil) + w = httptest.NewRecorder() + h.ServeNewsRSS(w, req) + if w.Code != 200 || w.Header().Get("Content-Type") != "application/rss+xml; charset=utf-8" { + t.Fatal("news.rss failed") + } + + // durable-thread-workers + req = httptest.NewRequest("POST", "/api/durable-thread-workers/xyz", nil) + w = httptest.NewRecorder() + h.ServeDurableThreadWorker(w, req) + if w.Code != 200 { + t.Fatal("durable-thread-workers failed") + } +} + +func TestThreadsFindHTTP(t *testing.T) { + s, dir := setupTestState(t) + h := NewHandler(s) + + writeTestThread(t, dir, &ThreadFile{ + ID: "find-1", + Title: ptr("Alpha Thread"), + Created: ptr(uint64(1000)), + }) + + req := httptest.NewRequest("GET", "/api/threads/find?q=alpha&limit=10", nil) + w := httptest.NewRecorder() + h.ServeThreadsFind(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp struct { + Threads []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"threads"` + HasMore bool `json:"hasMore"` + } + json.NewDecoder(w.Body).Decode(&resp) + if len(resp.Threads) != 1 || resp.Threads[0].ID != "find-1" { + t.Fatalf("unexpected response: %+v", resp) + } +} diff --git a/amplocal/handler.go b/amplocal/handler.go new file mode 100644 index 0000000..f98b41b --- /dev/null +++ b/amplocal/handler.go @@ -0,0 +1,569 @@ +package amplocal + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" +) + +// ShouldHandleLocally returns true if the given URL path should be served +// from local thread data instead of proxied to ampcode.com. +func ShouldHandleLocally(path string) bool { + p := strings.TrimPrefix(path, "/api/") + if p == path { + // Not under /api/ — check other known paths. + return path == "/news.rss" + } + switch { + case p == "internal": + return true + case p == "telemetry": + return true + case p == "attachments": + return true + case strings.HasPrefix(p, "threads/"): + return true + case strings.HasPrefix(p, "threads"): + return true + case strings.HasPrefix(p, "durable-thread-workers/"): + return true + case strings.HasPrefix(p, "durable-thread-workers"): + return true + case strings.HasPrefix(p, "users/"): + return true + case strings.HasPrefix(p, "users"): + return true + default: + return false + } +} + +// Handler serves local amp API requests. +type Handler struct { + state *State +} + +// NewHandler creates a handler backed by the given state. +func NewHandler(state *State) *Handler { + return &Handler{state: state} +} + +// ServeThreadsFind handles GET /api/threads/find. +func (h *Handler) ServeThreadsFind(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + query := q.Get("q") + limit := intParam(q.Get("limit"), 50) + offset := intParam(q.Get("offset"), 0) + + entries, hasMore := h.state.Search(query, limit, offset) + + type threadResult struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + CreatorUserID string `json:"creatorUserID"` + Created *uint64 `json:"created,omitempty"` + UpdatedAt uint64 `json:"updatedAt"` + MessageCount int `json:"messageCount"` + MatchedSearchText string `json:"matchedSearchText,omitempty"` + } + + threads := make([]threadResult, len(entries)) + for i, e := range entries { + threads[i] = threadResult{ + ID: e.ID, + Title: e.Title, + CreatorUserID: "local-user", + Created: e.Created, + UpdatedAt: e.UpdatedAt, + MessageCount: e.MessageCount, + } + // Return a snippet of search text if there was a query. + if query != "" && len(e.SearchText) > 0 { + snippet := e.SearchText + if len(snippet) > 200 { + snippet = snippet[:200] + } + threads[i].MatchedSearchText = snippet + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "threads": threads, + "hasMore": hasMore, + }) +} + +// ServeThreadMarkdown handles GET /api/threads/{id}.md. +func (h *Handler) ServeThreadMarkdown(w http.ResponseWriter, r *http.Request) { + // Extract thread ID from path: /api/threads/{id}.md + path := r.URL.Path + path = strings.TrimPrefix(path, "/api/threads/") + id := strings.TrimSuffix(path, ".md") + if id == "" { + http.Error(w, "missing thread id", http.StatusBadRequest) + return + } + + tf, err := h.state.ReadThread(id) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "thread not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + truncate := r.URL.Query().Get("truncate_tool_results") == "1" + md := renderMarkdown(tf, truncate) + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write([]byte(md)) +} + +// TryServeInternal handles /api/internal requests. Returns true if handled. +func (h *Handler) TryServeInternal(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodPost { + return false + } + + query := r.URL.RawQuery + method := query + if i := strings.IndexByte(method, '&'); i >= 0 { + method = method[:i] + } + + switch method { + case "uploadThread": + h.handleUploadThread(w, r) + return true + case "setThreadMeta": + h.handleSetThreadMeta(w, r) + return true + case "deleteThread": + h.handleDeleteThread(w, r) + return true + case "getUserInfo": + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "result": map[string]any{ + "id": "local-user", + "email": "local@localhost", + "displayName": "Local User", + "avatarURL": nil, + "features": []any{}, + "team": nil, + "mysteriousMessage": nil, + }, + }) + return true + case "shareThread": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{}}) + return true + case "getThreadLabels": + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "result": map[string]any{"labels": []any{}}, + }) + return true + case "setThreadLabels": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{}}) + return true + case "addThreadLabels": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{}}) + return true + case "listThreads": + h.handleListThreads(w, r) + return true + case "getThread": + h.handleGetThread(w, r) + return true + case "getThreadLinkInfo": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": nil}) + return true + case "createRemoteExecutorThread": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": nil}) + return true + case "shareThreadWithOperator": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{"url": ""}}) + return true + case "getUserLabels": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": []any{}}) + return true + case "threadDisplayCostInfo": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": nil}) + return true + case "userDisplayBalanceInfo": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": nil}) + return true + case "getUserFreeTierStatus": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{"canUseAmpFree": false, "isDailyGrantEnabled": false}}) + return true + case "github-auth-status": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{"authenticated": true}}) + return true + case "listTasks": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": map[string]any{"tasks": []any{}}}) + return true + case "markAsReadMysteriousMessage": + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return true + default: + // Catch-all for unknown methods in local mode — return ok stub + slog.Debug("amplocal: unhandled internal method", "method", method) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": nil}) + return true + } +} + +func (h *Handler) handleListThreads(w http.ResponseWriter, r *http.Request) { + entries := h.state.ListAll() + + type threadSummary struct { + ID string `json:"id"` + V *uint64 `json:"v,omitempty"` + Title *string `json:"title,omitempty"` + Created *uint64 `json:"created,omitempty"` + UpdatedAt uint64 `json:"updatedAt"` + UserLastInteractedAt uint64 `json:"userLastInteractedAt"` + MessageCount int `json:"messageCount"` + AgentMode *string `json:"agentMode,omitempty"` + Archived bool `json:"archived"` + UsesDTW bool `json:"usesDtw"` + Env json.RawMessage `json:"env,omitempty"` + Relationships json.RawMessage `json:"relationships,omitempty"` + SummaryStats map[string]any `json:"summaryStats"` + } + + threads := make([]threadSummary, 0, len(entries)) + for _, e := range entries { + if e.MessageCount == 0 { + continue + } + threads = append(threads, threadSummary{ + ID: e.ID, + V: e.V, + Title: e.Title, + Created: e.Created, + UpdatedAt: e.UpdatedAt, + UserLastInteractedAt: e.UpdatedAt, + MessageCount: e.MessageCount, + AgentMode: e.AgentMode, + Archived: e.Archived, + UsesDTW: e.UsesDTW, + Env: e.Env, + Relationships: e.Relationships, + SummaryStats: map[string]any{ + "messageCount": e.MessageCount, + "diffStats": nil, + }, + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "result": map[string]any{"threads": threads}, + }) +} + +func (h *Handler) handleGetThread(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Try params.thread, params.threadId, threadId at top level + var req struct { + ThreadID string `json:"threadId"` + Params struct { + ThreadID string `json:"threadId"` + Thread string `json:"thread"` + } `json:"params"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + threadID := req.ThreadID + if threadID == "" { + threadID = req.Params.ThreadID + } + if threadID == "" { + threadID = req.Params.Thread + } + if threadID == "" { + slog.Debug("amplocal: getThread missing threadId", "body", string(body[:min(300, len(body))])) + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": map[string]any{"code": "invalid-request", "message": "missing thread id"}}) + return + } + + // Read raw JSON to preserve all fields exactly + rawData, err := h.state.ReadThreadRaw(threadID) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "error": map[string]any{ + "code": "thread-not-found", + "message": fmt.Sprintf("Thread %s not found", threadID), + }, + }) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + // Parse as generic JSON to preserve all fields + var threadData json.RawMessage = rawData + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"result":{"thread":{"data":%s},"run":{"status":"completed"}}}`+"\n", string(threadData)) +} + +func (h *Handler) handleUploadThread(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + slog.Error("amplocal: upload read body", "error", err, "content-encoding", r.Header.Get("Content-Encoding")) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Amp may wrap thread data in {"params": {"thread": ...}} envelope. + var envelope struct { + Params struct { + Thread json.RawMessage `json:"thread"` + } `json:"params"` + } + threadData := body + if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Params.Thread) > 0 { + threadData = envelope.Params.Thread + } + + // Only extract ID for file naming — write raw JSON to preserve all fields + var partial struct { + ID string `json:"id"` + } + if err := json.Unmarshal(threadData, &partial); err != nil { + slog.Error("amplocal: upload unmarshal", "error", err, "bodyLen", len(body), "snippet", string(body[:min(200, len(body))])) + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } + if partial.ID == "" { + http.Error(w, "missing thread id", http.StatusBadRequest) + return + } + + if err := h.state.WriteThreadRaw(partial.ID, threadData); err != nil { + slog.Error("amplocal: write thread", "id", partial.ID, "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) handleSetThreadMeta(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var req struct { + ThreadID string `json:"threadId"` + Meta json.RawMessage `json:"meta"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + if err := h.state.MergeMeta(req.ThreadID, req.Meta); err != nil { + if os.IsNotExist(err) { + http.Error(w, "thread not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) handleDeleteThread(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var req struct { + ThreadID string `json:"threadId"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + if err := h.state.DeleteThread(req.ThreadID); err != nil && !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// ServeTelemetry handles POST /api/telemetry — no-op. +func (h *Handler) ServeTelemetry(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +// ServeDurableThreadWorker handles POST /api/durable-thread-workers/{id}. +func (h *Handler) ServeDurableThreadWorker(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/durable-thread-workers/") + if id == "" { + id = "unknown" + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": id, + "status": "running", + "executorType": "local-client", + }) +} + +// ServeUsers handles GET /api/users/{id}. +func (h *Handler) ServeUsers(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "id": "local-user", + "name": "Local User", + "email": "local@localhost", + }) +} + +// ServeAttachments handles GET /api/attachments. +func (h *Handler) ServeAttachments(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"attachments": []any{}}) +} + +// ServeNewsRSS handles GET /news.rss. +func (h *Handler) ServeNewsRSS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + w.Write([]byte(` + + + Amp News + Local mode — no news + +`)) +} + +// --- helpers --- + +func readBody(r *http.Request) ([]byte, error) { + var reader io.Reader = r.Body + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(r.Body) + if err != nil { + return nil, fmt.Errorf("gzip decode: %w", err) + } + defer gz.Close() + reader = gz + } + return io.ReadAll(reader) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func intParam(s string, def int) int { + if s == "" { + return def + } + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return def + } + return n +} + +// renderMarkdown converts a thread to markdown. +func renderMarkdown(tf *ThreadFile, truncateToolResults bool) string { + var sb strings.Builder + + // Title + if tf.Title != nil { + sb.WriteString("# ") + sb.WriteString(*tf.Title) + sb.WriteString("\n\n") + } + + // Metadata + sb.WriteString(fmt.Sprintf("Thread ID: %s\n", tf.ID)) + if tf.Created != nil { + sb.WriteString(fmt.Sprintf("Created: %d\n", *tf.Created)) + } + if tf.AgentMode != nil { + sb.WriteString(fmt.Sprintf("Agent Mode: %s\n", *tf.AgentMode)) + } + sb.WriteString("\n---\n\n") + + // Messages + for _, m := range tf.Messages { + role := "unknown" + if m.Role != nil { + role = *m.Role + } + sb.WriteString(fmt.Sprintf("## %s\n\n", role)) + + for _, c := range m.Content { + ctype := "" + if c.Type != nil { + ctype = *c.Type + } + + switch ctype { + case "text": + if c.Text != nil { + sb.WriteString(*c.Text) + sb.WriteString("\n\n") + } + case "tool_use": + name := "" + if c.Name != nil { + name = *c.Name + } + sb.WriteString(fmt.Sprintf("**Tool Use: %s**\n", name)) + if len(c.Input) > 0 { + sb.WriteString("```json\n") + sb.Write(c.Input) + sb.WriteString("\n```\n\n") + } + case "tool_result": + sb.WriteString("**Tool Result**\n") + if truncateToolResults { + sb.WriteString("_(truncated)_\n\n") + } else if len(c.Content) > 0 { + sb.WriteString("```\n") + sb.Write(c.Content) + sb.WriteString("\n```\n\n") + } + default: + if c.Text != nil { + sb.WriteString(*c.Text) + sb.WriteString("\n\n") + } + } + } + } + + return sb.String() +} diff --git a/amplocal/state.go b/amplocal/state.go new file mode 100644 index 0000000..f0ef579 --- /dev/null +++ b/amplocal/state.go @@ -0,0 +1,324 @@ +package amplocal + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// indexEntry holds pre-computed metadata for a single thread file. +type indexEntry struct { + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Created *uint64 `json:"created,omitempty"` + UpdatedAt uint64 `json:"updatedAt"` + MessageCount int `json:"messageCount"` + AgentMode *string `json:"agentMode,omitempty"` + V *uint64 `json:"v,omitempty"` + Env json.RawMessage `json:"env,omitempty"` + Relationships json.RawMessage `json:"relationships,omitempty"` + UsesDTW bool `json:"usesDtw,omitempty"` + Archived bool `json:"archived,omitempty"` + SearchText string `json:"-"` +} + +// State holds the in-memory thread index and the threads directory path. +type State struct { + dir string + mu sync.RWMutex + entries []indexEntry + builtAt time.Time + staleAfter time.Duration +} + +// NewState creates a new local amp state rooted at the given threads directory. +func NewState(dir string) *State { + return &State{ + dir: dir, + staleAfter: 5 * time.Second, + } +} + +// ensureIndex rebuilds the index if it's stale (older than 5 seconds). +func (s *State) ensureIndex() { + s.mu.RLock() + fresh := time.Since(s.builtAt) < s.staleAfter + s.mu.RUnlock() + if fresh { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + // Double-check after acquiring write lock. + if time.Since(s.builtAt) < s.staleAfter { + return + } + s.rebuildLocked() +} + +func (s *State) rebuildLocked() { + files, err := filepath.Glob(filepath.Join(s.dir, "*.json")) + if err != nil { + slog.Error("amplocal: glob threads", "error", err) + return + } + + entries := make([]indexEntry, 0, len(files)) + for _, f := range files { + e, err := indexFile(f) + if err != nil { + slog.Debug("amplocal: skip file", "path", f, "error", err) + continue + } + entries = append(entries, e) + } + + // Sort by updatedAt descending. + sort.Slice(entries, func(i, j int) bool { + return entries[i].UpdatedAt > entries[j].UpdatedAt + }) + + s.entries = entries + s.builtAt = time.Now() +} + +func indexFile(path string) (indexEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + return indexEntry{}, err + } + var tf ThreadFile + if err := json.Unmarshal(data, &tf); err != nil { + return indexEntry{}, err + } + + e := indexEntry{ + ID: tf.ID, + Title: tf.Title, + Created: tf.Created, + MessageCount: len(tf.Messages), + AgentMode: tf.AgentMode, + V: tf.V, + Env: tf.Env, + Relationships: tf.Relationships, + } + if tf.Archived != nil { + e.Archived = *tf.Archived + } + + // updatedAt: use created as base, then check message meta for sentAt. + if tf.Created != nil { + e.UpdatedAt = *tf.Created + } + for _, m := range tf.Messages { + if len(m.Meta) > 0 { + var mm struct { + SentAt *uint64 `json:"sentAt"` + } + if json.Unmarshal(m.Meta, &mm) == nil && mm.SentAt != nil && *mm.SentAt > e.UpdatedAt { + e.UpdatedAt = *mm.SentAt + } + } + } + + // usesDtw from meta.usesDtw + if len(tf.Meta) > 0 { + var meta struct { + UsesDTW *bool `json:"usesDtw"` + } + if json.Unmarshal(tf.Meta, &meta) == nil && meta.UsesDTW != nil { + e.UsesDTW = *meta.UsesDTW + } + } + + // Build search text from title + first 20 user/assistant text blocks. + var sb strings.Builder + if tf.Title != nil { + sb.WriteString(*tf.Title) + sb.WriteByte(' ') + } + textCount := 0 + for _, m := range tf.Messages { + if textCount >= 20 { + break + } + if m.Role == nil { + continue + } + role := *m.Role + if role != "user" && role != "assistant" { + continue + } + for _, c := range m.Content { + if textCount >= 20 { + break + } + if c.Type != nil && *c.Type == "text" && c.Text != nil { + sb.WriteString(*c.Text) + sb.WriteByte(' ') + textCount++ + } + } + } + e.SearchText = strings.ToLower(sb.String()) + + return e, nil +} + +// Search finds threads matching the query. Returns matching entries and whether there are more. +// ListAll returns all indexed threads (sorted by updatedAt desc). +func (s *State) ListAll() []indexEntry { + s.ensureIndex() + + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]indexEntry, len(s.entries)) + copy(result, s.entries) + return result +} + +func (s *State) Search(query string, limit, offset int) ([]indexEntry, bool) { + s.ensureIndex() + + s.mu.RLock() + defer s.mu.RUnlock() + + if limit <= 0 { + limit = 50 + } + + // Parse query words, extract prefix filters. + words := strings.Fields(strings.ToLower(query)) + var textWords []string + for _, w := range words { + // Skip author: and file: filters (not applicable locally). + if strings.HasPrefix(w, "author:") || strings.HasPrefix(w, "file:") { + continue + } + textWords = append(textWords, w) + } + + var results []indexEntry + for _, e := range s.entries { + if len(textWords) > 0 { + match := true + for _, w := range textWords { + if !strings.Contains(e.SearchText, w) { + match = false + break + } + } + if !match { + continue + } + } + results = append(results, e) + } + + total := len(results) + if offset > total { + offset = total + } + results = results[offset:] + hasMore := len(results) > limit + if hasMore { + results = results[:limit] + } + return results, hasMore +} + +// ReadThread reads and parses a thread file by ID. +func (s *State) ReadThread(id string) (*ThreadFile, error) { + path := filepath.Join(s.dir, id+".json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var tf ThreadFile + if err := json.Unmarshal(data, &tf); err != nil { + return nil, err + } + return &tf, nil +} + +// ReadThreadRaw returns raw JSON bytes for a thread file. +func (s *State) ReadThreadRaw(id string) ([]byte, error) { + path := filepath.Join(s.dir, id+".json") + return os.ReadFile(path) +} + +// WriteThread writes a thread file to disk (typed struct — may lose unknown fields). +func (s *State) WriteThread(tf *ThreadFile) error { + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return err + } + data, err := json.Marshal(tf) + if err != nil { + return err + } + path := filepath.Join(s.dir, tf.ID+".json") + return os.WriteFile(path, data, 0o644) +} + +// WriteThreadRaw writes raw JSON bytes as a thread file, preserving all fields. +func (s *State) WriteThreadRaw(id string, data []byte) error { + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return err + } + path := filepath.Join(s.dir, id+".json") + return os.WriteFile(path, data, 0o644) +} + +// DeleteThread removes a thread file from disk. +func (s *State) DeleteThread(id string) error { + path := filepath.Join(s.dir, id+".json") + return os.Remove(path) +} + +// MergeMeta reads raw thread JSON, merges the given meta fields, and writes back. +func (s *State) MergeMeta(id string, metaPatch json.RawMessage) error { + rawData, err := s.ReadThreadRaw(id) + if err != nil { + return err + } + + // Parse as generic map to preserve all fields + var doc map[string]json.RawMessage + if err := json.Unmarshal(rawData, &doc); err != nil { + return err + } + + existing := make(map[string]json.RawMessage) + if meta, ok := doc["meta"]; ok && len(meta) > 0 { + if err := json.Unmarshal(meta, &existing); err != nil { + existing = make(map[string]json.RawMessage) + } + } + + patch := make(map[string]json.RawMessage) + if err := json.Unmarshal(metaPatch, &patch); err != nil { + return err + } + for k, v := range patch { + existing[k] = v + } + + merged, err := json.Marshal(existing) + if err != nil { + return err + } + doc["meta"] = merged + + newData, err := json.Marshal(doc) + if err != nil { + return err + } + return s.WriteThreadRaw(id, newData) +} diff --git a/amplocal/types.go b/amplocal/types.go new file mode 100644 index 0000000..0aee446 --- /dev/null +++ b/amplocal/types.go @@ -0,0 +1,44 @@ +package amplocal + +import "encoding/json" + +// ThreadFile represents a thread stored in ~/.local/share/amp/threads/*.json. +type ThreadFile struct { + V *uint64 `json:"v,omitempty"` + ID string `json:"id"` + Created *uint64 `json:"created,omitempty"` + Messages []ThreadMessage `json:"messages,omitempty"` + AgentMode *string `json:"agentMode,omitempty"` + NextMessageID *uint64 `json:"nextMessageId,omitempty"` + Title *string `json:"title,omitempty"` + Env json.RawMessage `json:"env,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` + Debug json.RawMessage `json:"~debug,omitempty"` + ActivatedSkills json.RawMessage `json:"activatedSkills,omitempty"` + Relationships json.RawMessage `json:"relationships,omitempty"` + Archived *bool `json:"archived,omitempty"` + OriginThreadID *string `json:"originThreadID,omitempty"` + MainThreadID *string `json:"mainThreadID,omitempty"` +} + +// ThreadMessage is a single message inside a thread. +type ThreadMessage struct { + Role *string `json:"role,omitempty"` + MessageID *uint64 `json:"messageId,omitempty"` + Content []ContentBlock `json:"content,omitempty"` + UserState json.RawMessage `json:"userState,omitempty"` + AgentMode *string `json:"agentMode,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` + State json.RawMessage `json:"state,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"` +} + +// ContentBlock represents a content block within a message. +type ContentBlock struct { + Type *string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + Name *string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + ToolUseID *string `json:"tool_use_id,omitempty"` +} diff --git a/ampsearch/handler.go b/ampsearch/handler.go new file mode 100644 index 0000000..a3b6389 --- /dev/null +++ b/ampsearch/handler.go @@ -0,0 +1,148 @@ +package ampsearch + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" +) + +type SearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` +} + +type PageContent struct { + FullContent string `json:"fullContent"` + Excerpts []string `json:"excerpts"` +} + +type Backend interface { + Search(queries []string, maxResults int) ([]SearchResult, error) + ExtractPage(pageURL string) (*PageContent, error) +} + +type Handler struct { + backend Backend +} + +func NewHandler(backend Backend) *Handler { + return &Handler{backend: backend} +} + +func (h *Handler) TryServe(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodPost { + return false + } + + query := r.URL.RawQuery + method := query + if i := strings.IndexByte(method, '&'); i >= 0 { + method = method[:i] + } + + switch method { + case "webSearch2": + h.handleWebSearch(w, r) + return true + case "extractWebPageContent": + h.handleExtractPage(w, r) + return true + default: + return false + } +} + +func (h *Handler) handleWebSearch(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": map[string]string{"message": "failed to read body"}}) + return + } + + var parsed struct { + Params struct { + SearchQueries []string `json:"searchQueries"` + Objective string `json:"objective"` + MaxResults int `json:"maxResults"` + } `json:"params"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": map[string]string{"message": "invalid json"}}) + return + } + + queries := parsed.Params.SearchQueries + if len(queries) == 0 && parsed.Params.Objective != "" { + queries = []string{parsed.Params.Objective} + } + maxResults := parsed.Params.MaxResults + if maxResults <= 0 { + maxResults = 5 + } + + results, err := h.backend.Search(queries, maxResults) + if err != nil { + slog.Warn("web search failed", "error", err) + results = []SearchResult{} + } + if results == nil { + results = []SearchResult{} + } + + writeJSON(w, map[string]any{ + "ok": true, + "result": map[string]any{ + "results": results, + "showParallelAttribution": false, + }, + }) +} + +func (h *Handler) handleExtractPage(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": map[string]string{"message": "failed to read body"}}) + return + } + + var parsed struct { + Params struct { + URL string `json:"url"` + } `json:"params"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": map[string]string{"message": "invalid json"}}) + return + } + + if parsed.Params.URL == "" { + writeJSON(w, map[string]any{ + "ok": false, + "error": map[string]string{"code": "invalid-request", "message": "missing url"}, + }) + return + } + + page, err := h.backend.ExtractPage(parsed.Params.URL) + if err != nil { + slog.Warn("page extraction failed", "url", parsed.Params.URL, "error", err) + writeJSON(w, map[string]any{ + "ok": false, + "error": map[string]string{"code": "upstream-error", "message": err.Error()}, + }) + return + } + + writeJSON(w, map[string]any{ + "ok": true, + "result": page, + }) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} diff --git a/ampsearch/model.go b/ampsearch/model.go new file mode 100644 index 0000000..d3bd8f5 --- /dev/null +++ b/ampsearch/model.go @@ -0,0 +1,213 @@ +package ampsearch + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/whtsky/copilot2api/internal/upstream" +) + +type ModelBackend struct { + client *upstream.Client + model string + httpClient *http.Client +} + +func NewModelBackend(client *upstream.Client, model string) *ModelBackend { + if model == "" { + model = "gpt-5-mini" + } + return &ModelBackend{client: client, model: model, httpClient: &http.Client{Timeout: 30 * time.Second}} +} + +func (m *ModelBackend) Search(queries []string, maxResults int) ([]SearchResult, error) { + var allResults []SearchResult + for _, q := range queries { + if q == "" { + continue + } + results, err := m.searchOne(q) + if err != nil { + slog.Warn("model search failed", "query", q, "error", err) + continue + } + allResults = append(allResults, results...) + if len(allResults) >= maxResults { + break + } + } + if len(allResults) > maxResults { + allResults = allResults[:maxResults] + } + return allResults, nil +} + +func (m *ModelBackend) searchOne(query string) ([]SearchResult, error) { + body := map[string]any{ + "model": m.model, + "input": fmt.Sprintf("Search the web for: %s. Return a list of the most relevant results.", query), + "tools": []map[string]any{{"type": "web_search"}}, + } + + _, respBody, err := m.client.Do(context.Background(), upstream.Request{ + Method: "POST", + Endpoint: "/responses", + Body: body, + ExtraHeaders: map[string]string{ + "Content-Type": "application/json", + }, + }) + if err != nil { + return nil, fmt.Errorf("copilot request failed: %w", err) + } + + var resp struct { + Output []struct { + Type string `json:"type"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Annotations []struct { + Type string `json:"type"` + URL string `json:"url"` + Title string `json:"title"` + StartIndex int `json:"start_index"` + EndIndex int `json:"end_index"` + } `json:"annotations"` + } `json:"content"` + } `json:"output"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + var results []SearchResult + seen := make(map[string]bool) + + for _, item := range resp.Output { + if item.Type != "message" { + continue + } + for _, block := range item.Content { + if block.Type != "output_text" { + continue + } + for _, ann := range block.Annotations { + if ann.Type != "url_citation" || ann.URL == "" || seen[ann.URL] { + continue + } + seen[ann.URL] = true + title := ann.Title + if title == "" { + title = ann.URL + } + snippet := extractCitationContext(block.Text, ann.StartIndex, ann.EndIndex) + results = append(results, SearchResult{ + Title: title, + URL: ann.URL, + Content: snippet, + }) + } + if len(results) == 0 && block.Text != "" { + results = append(results, SearchResult{ + Title: query, + Content: block.Text, + }) + } + } + } + return results, nil +} + +func extractCitationContext(text string, start, end int) string { + if start > 0 && start <= len(text) { + before := text[:start] + contextStart := len(before) + if idx := lastIndex(before, "\n\n"); idx >= 0 { + contextStart = idx + 2 + } else if idx := lastIndex(before, ". "); idx >= 0 { + contextStart = idx + 2 + } else if contextStart > 200 { + contextStart = len(before) - 200 + } + snippet := before[contextStart:] + snippet = trimRight(snippet, "([") + if snippet != "" { + return snippet + } + } + if end > 0 && end < len(text) { + s := end - 200 + if s < 0 { + s = 0 + } + return text[s:end] + } + return "" +} + +func (m *ModelBackend) ExtractPage(pageURL string) (*PageContent, error) { + req, err := http.NewRequest("GET", "https://r.jina.ai/"+pageURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-No-Cache", "true") + if key := os.Getenv("JINA_API_KEY"); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + + resp, err := m.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("jina reader request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jina reader returned %d", resp.StatusCode) + } + + var body struct { + Data struct { + Content string `json:"content"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, err + } + return &PageContent{ + FullContent: body.Data.Content, + Excerpts: []string{}, + }, nil +} + +func lastIndex(s, substr string) int { + for i := len(s) - len(substr); i >= 0; i-- { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +func trimRight(s string, cutset string) string { + for len(s) > 0 { + found := false + for _, c := range cutset { + if rune(s[len(s)-1]) == c { + s = s[:len(s)-1] + found = true + break + } + } + if !found { + break + } + } + return s +} diff --git a/anthropic/handler.go b/anthropic/handler.go index 244b923..c9af710 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -19,16 +19,18 @@ import ( // Handler handles Anthropic Messages API requests type Handler struct { - upstream *upstream.Client - models *models.Cache + upstream *upstream.Client + models *models.Cache + noModelUpgrade bool } // NewHandler creates a new Anthropic handler. // The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache) *Handler { +func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, noModelUpgrade bool, debug bool) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport), - models: mc, + upstream: upstream.NewClient(authClient, transport, debug), + models: mc, + noModelUpgrade: noModelUpgrade, } } @@ -64,16 +66,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Resolve model alias (e.g. claude-haiku-4-5-20251001 -> claude-haiku-4.5) resolvedModel := resolveModelAlias(anthropicReq.Model) - // Detect 1M context variant: Claude Code signals this via the anthropic-beta - // header (e.g. "context-1m-2025-08-07"). Copilot exposes these as separate - // model IDs with a "-1m" suffix (e.g. "claude-opus-4.6-1m"), so we append it. - if betaHeader := r.Header.Get("anthropic-beta"); betaHeader != "" { - if context1mRe.MatchString(betaHeader) && !strings.HasSuffix(resolvedModel, "-1m") { - slog.Debug("detected context-1m beta header, appending -1m suffix", "model", resolvedModel) - resolvedModel += "-1m" - } - } - modelChanged := resolvedModel != anthropicReq.Model if modelChanged { slog.Debug("resolved model alias", "from", anthropicReq.Model, "to", resolvedModel) @@ -85,7 +77,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Info("anthropic request", "endpoint", "/v1/messages", "model", anthropicReq.Model, "stream", anthropicReq.Stream, "messages", len(anthropicReq.Messages), "route", route, "duration_ms", time.Since(start).Milliseconds()) }() - modelInfo, capabilityFetchFailed := h.getModelInfo(r.Context(), anthropicReq.Model) + upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade) + modelUpgraded := upgradedModel != anthropicReq.Model + if modelUpgraded { + slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) + anthropicReq.Model = upgradedModel + } if modelSupportsEndpoint(modelInfo, "/v1/messages") { route = "native" @@ -95,8 +92,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Only re-encode the body for native passthrough (the only path that // sends raw reqBody). Responses and Chat Completions paths use the // parsed struct, so they skip this JSON round-trip. - if modelChanged || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { - newBody, err := normalizeNativeMessagesBody(reqBody, resolvedModel, modelChanged) + if modelChanged || modelUpgraded || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { + newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged || modelUpgraded) if err != nil { WriteAnthropicError(w, http.StatusBadRequest, AnthropicErrorTypeInvalidRequest, fmt.Sprintf("Invalid JSON: %v", err)) return diff --git a/anthropic/models.go b/anthropic/models.go index afbd7b5..6e257ab 100644 --- a/anthropic/models.go +++ b/anthropic/models.go @@ -25,10 +25,6 @@ var versionHyphenRe = regexp.MustCompile(`([a-zA-Z]-)(\d)-(\d)([^0-9]|$)`) // at the end of a model ID (optionally followed by more digits for timestamps). var dateSuffixRe = regexp.MustCompile(`-(\d{8,})$`) -// context1mRe matches the "context-1m" token in the anthropic-beta header, -// used by Claude Code to signal the 1M context window variant. -var context1mRe = regexp.MustCompile(`\bcontext-1m\b`) - // resolveModelAlias returns the canonical model ID for Copilot's model list. // It applies the following transformations in order: // 1. Strip date suffixes (e.g. "-20250514") @@ -57,17 +53,37 @@ func resolveModelAlias(modelID string) string { return modelID } -// getModelInfo returns cached model info, fetching from upstream if needed. -func (h *Handler) getModelInfo(ctx context.Context, modelID string) (*models.Info, bool) { - modelID = resolveModelAlias(modelID) +// modelUpgradeSuffixes lists suffixes to try (in order) when upgrading a model +// to the best available variant. The first match wins. +var modelUpgradeSuffixes = []string{"-1m-internal", "-1m"} + +// upgradeModel returns the best available variant of modelID by checking for +// known suffixes in the upstream model list. Returns the original if no better variant exists. +func upgradeModel(modelID string, available map[string]*models.Info) string { + for _, suffix := range modelUpgradeSuffixes { + candidate := modelID + suffix + if _, ok := available[candidate]; ok { + slog.Debug("auto-upgraded model", "from", modelID, "to", candidate) + return candidate + } + } + return modelID +} +// getModelInfoWithUpgrade fetches model info and auto-upgrades the model +// to the best available variant (e.g. appending "-1m-internal" if available). +// Set skipUpgrade to true to disable auto-upgrade. +func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string, skipUpgrade bool) (string, *models.Info, bool) { infoMap, err := h.models.GetInfo(ctx) if err != nil { slog.Error("failed to fetch models for capability detection", "error", err) - return nil, true + return modelID, nil, true } - return infoMap[modelID], false + if !skipUpgrade { + modelID = upgradeModel(modelID, infoMap) + } + return modelID, infoMap[modelID], false } func modelSupportsEndpoint(info *models.Info, endpoint string) bool { diff --git a/anthropic/models_test.go b/anthropic/models_test.go index 7fb6da0..bd64cd7 100644 --- a/anthropic/models_test.go +++ b/anthropic/models_test.go @@ -82,3 +82,32 @@ func TestResolveModelAlias(t *testing.T) { }) } } + +func TestUpgradeModel(t *testing.T) { + available := map[string]*models.Info{ + "claude-opus-4.7": {ID: "claude-opus-4.7"}, + "claude-opus-4.7-1m-internal": {ID: "claude-opus-4.7-1m-internal"}, + "claude-opus-4.6": {ID: "claude-opus-4.6"}, + "claude-opus-4.6-1m": {ID: "claude-opus-4.6-1m"}, + "claude-sonnet-4.6": {ID: "claude-sonnet-4.6"}, + } + + tests := []struct { + input string + want string + }{ + {"claude-opus-4.7", "claude-opus-4.7-1m-internal"}, + {"claude-opus-4.6", "claude-opus-4.6-1m"}, + {"claude-sonnet-4.6", "claude-sonnet-4.6"}, + {"claude-sonnet-5.0", "claude-sonnet-5.0"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := upgradeModel(tt.input, available) + if got != tt.want { + t.Errorf("upgradeModel(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/gemini/handler.go b/gemini/handler.go index 53f0836..90230f0 100644 --- a/gemini/handler.go +++ b/gemini/handler.go @@ -25,8 +25,8 @@ type Handler struct { models *models.Cache } -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache) *Handler { - return &Handler{upstream: upstream.NewClient(authClient, transport), models: mc} +func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { + return &Handler{upstream: upstream.NewClient(authClient, transport, debug), models: mc} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/gemini/handler_test.go b/gemini/handler_test.go index 149a342..6b6b634 100644 --- a/gemini/handler_test.go +++ b/gemini/handler_test.go @@ -44,7 +44,7 @@ func TestHandler_ModelsListsGeminiMethods(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{upstream: uc, models: models.NewCache(uc, 5*time.Minute)} req := httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) @@ -116,7 +116,7 @@ func TestHandler_GenerateContent_NonStreaming(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{upstream: uc, models: models.NewCache(uc, 5*time.Minute)} body := "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hello\"}]}]}" @@ -162,7 +162,7 @@ func TestHandler_StreamGenerateContent_SSE(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{upstream: uc, models: models.NewCache(uc, 5*time.Minute)} body := "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"hello\"}]}]}" diff --git a/internal/upstream/client.go b/internal/upstream/client.go index e99158b..ba2f94c 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/http" "time" @@ -26,6 +27,7 @@ type TokenProvider interface { type Client struct { TokenProvider TokenProvider HTTPClient *http.Client + Debug bool } // NewTransport creates a shared http.Transport suitable for upstream requests. @@ -44,7 +46,7 @@ func NewTransport() *http.Transport { // NewClient creates a new upstream Client. // If transport is non-nil it is used for the underlying http.Client; // otherwise a new Transport is created. -func NewClient(tp TokenProvider, transport *http.Transport) *Client { +func NewClient(tp TokenProvider, transport *http.Transport, debug bool) *Client { if transport == nil { transport = NewTransport() } @@ -55,6 +57,7 @@ func NewClient(tp TokenProvider, transport *http.Transport) *Client { // Non-streaming requests use per-request context timeouts instead. Transport: transport, }, + Debug: debug, } } @@ -138,6 +141,20 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err req.Header.Set(k, v) } + // Debug log: outgoing request + if c.Debug { + if bodyReader != nil { + if br, ok := bodyReader.(*bytes.Reader); ok { + rawBytes := make([]byte, br.Len()) + br.Read(rawBytes) + br.Seek(0, io.SeekStart) + slog.Debug("upstream request", "method", method, "url", upstreamURL, "body", truncateStr(string(rawBytes), 2000)) + } + } else { + slog.Debug("upstream request", "method", method, "url", upstreamURL) + } + } + resp, err := c.HTTPClient.Do(req) if err != nil { return nil, nil, fmt.Errorf("request failed: %w", err) @@ -149,6 +166,7 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err if len(errBody) > maxErrBody { return nil, nil, fmt.Errorf("upstream error response too large (exceeds %d bytes)", maxErrBody) } + slog.Debug("upstream error response", "endpoint", r.Endpoint, "status", resp.StatusCode, "body", truncateStr(string(errBody), 2000)) return nil, nil, &UpstreamError{ StatusCode: resp.StatusCode, Body: errBody, @@ -167,5 +185,13 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err if len(respData) > maxRespBody { return nil, nil, fmt.Errorf("upstream response too large (exceeds %d bytes)", maxRespBody) } + slog.Debug("upstream response", "endpoint", r.Endpoint, "status", resp.StatusCode, "body", truncateStr(string(respData), 2000)) return nil, respData, nil } + +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/main.go b/main.go index da35bd0..9f538cb 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,8 @@ import ( "syscall" "time" + "github.com/whtsky/copilot2api/amplocal" + "github.com/whtsky/copilot2api/ampsearch" "github.com/whtsky/copilot2api/anthropic" "github.com/whtsky/copilot2api/auth" "github.com/whtsky/copilot2api/gemini" @@ -32,9 +34,12 @@ func main() { tokenDir = flag.String("token-dir", "", "Token storage directory (env: COPILOT2API_TOKEN_DIR, default: ~/.config/copilot2api)") showVersion = flag.Bool("version", false, "Show version and exit") debug = flag.Bool("debug", false, "Enable debug logging (env: COPILOT2API_DEBUG)") + ) flag.Parse() + + // Apply debug env var if !*debug { if v := os.Getenv("COPILOT2API_DEBUG"); v != "" { @@ -113,17 +118,23 @@ func main() { // Shared models cache — a single fetch populates both raw JSON (for // proxying GET /v1/models) and parsed model info (for capability detection). - upstreamClient := upstream.NewClient(authClient, transport) + upstreamClient := upstream.NewClient(authClient, transport, *debug) modelsCache := models.NewCache(upstreamClient, 5*time.Minute) // Initialize proxy handler - proxyHandler := proxy.NewHandler(authClient, transport, modelsCache) + proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug) // Initialize Anthropic handler - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache) + noModelUpgrade := false + if v := os.Getenv("COPILOT2API_NO_MODEL_UPGRADE"); v != "" { + if enabled, err := strconv.ParseBool(v); err == nil { + noModelUpgrade = enabled + } + } + anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, noModelUpgrade, *debug) // Initialize Gemini handler - geminiHandler := gemini.NewHandler(authClient, transport, modelsCache) + geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug) // Set up routes mux := http.NewServeMux() @@ -156,6 +167,37 @@ func main() { // AI inference stays on Copilot API (routes above); only metadata hits ampcode.com. ampBackend, _ := url.Parse("https://ampcode.com") ampReverseProxy := newAmpReverseProxy(ampBackend) + searchHandler := ampsearch.NewHandler(ampsearch.NewModelBackend(upstreamClient, "")) + + // Amp local mode: serve management APIs from local thread data (always enabled). + ampThreadsDir := os.Getenv("AMP_THREADS_DIR") + if ampThreadsDir == "" { + // Default: store threads alongside credentials in the config directory. + homeDir, _ := os.UserHomeDir() + ampThreadsDir = filepath.Join(homeDir, ".config", "copilot2api", "threads") + } + slog.Info("amp local mode enabled", "threads_dir", ampThreadsDir) + localState := amplocal.NewState(ampThreadsDir) + localHandler := amplocal.NewHandler(localState) + + mux.HandleFunc("/api/internal", func(w http.ResponseWriter, r *http.Request) { + if searchHandler.TryServe(w, r) { + return + } + if localHandler.TryServeInternal(w, r) { + return + } + ampReverseProxy.ServeHTTP(w, r) + }) + + mux.HandleFunc("/api/threads/find", localHandler.ServeThreadsFind) + mux.HandleFunc("/api/threads/", localHandler.ServeThreadMarkdown) + mux.HandleFunc("/api/telemetry", localHandler.ServeTelemetry) + mux.HandleFunc("/api/durable-thread-workers/", localHandler.ServeDurableThreadWorker) + mux.HandleFunc("/api/users/", localHandler.ServeUsers) + mux.HandleFunc("/api/attachments", localHandler.ServeAttachments) + mux.HandleFunc("/news.rss", localHandler.ServeNewsRSS) + mux.Handle("/api/", ampReverseProxy) mux.HandleFunc("/amp/v1/login", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://ampcode.com/login", http.StatusFound) diff --git a/proxy/handler.go b/proxy/handler.go index 982c487..5bdbff4 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -25,9 +25,9 @@ type Handler struct { // NewHandler creates a new proxy handler. // The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient *auth.Client, transport *http.Transport, mc *models.Cache) *Handler { +func NewHandler(authClient *auth.Client, transport *http.Transport, mc *models.Cache, debug bool) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport), + upstream: upstream.NewClient(authClient, transport, debug), authClient: authClient, modelsCache: mc, } diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 9a86665..5c04f8d 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -140,7 +140,7 @@ func TestHandler_ServeHTTP_Routing(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) handler := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -177,7 +177,7 @@ func TestHandler_HandleModels(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) handler := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -219,7 +219,7 @@ func TestHandler_HandlePassthrough(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) handler := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -345,7 +345,7 @@ func TestHandlePassthrough_StreamingNetworkFailure_Returns502(t *testing.T) { tp := &stubTokenProvider{baseURL: "http://" + addr} handler := &Handler{ - upstream: upstream.NewClient(tp, nil), + upstream: upstream.NewClient(tp, nil, false), } body := `{"model":"gpt-4","messages":[],"stream":true}` diff --git a/proxy/smart_routing_test.go b/proxy/smart_routing_test.go index 6bc5784..1a4ff01 100644 --- a/proxy/smart_routing_test.go +++ b/proxy/smart_routing_test.go @@ -63,7 +63,7 @@ func TestResolveTargetEndpoint_NoModelField(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -87,7 +87,7 @@ func TestResolveTargetEndpoint_UnknownModel(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -111,7 +111,7 @@ func TestResolveTargetEndpoint_ModelSupportsBoth(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -144,7 +144,7 @@ func TestResolveTargetEndpoint_NeedsConversion(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -176,7 +176,7 @@ func TestResolveTargetEndpoint_ModelSupportsNeither(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -237,7 +237,7 @@ func TestSmartRouting_ChatToResponsesNonStreaming(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -316,7 +316,7 @@ func TestSmartRouting_ResponsesToChatNonStreaming(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -362,7 +362,7 @@ func TestSmartRouting_PassthroughWhenModelSupportsEndpoint(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -418,7 +418,7 @@ func TestSmartRouting_ChatToResponsesStreaming(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), @@ -468,7 +468,7 @@ func TestSmartRouting_ResponsesToChatStreaming(t *testing.T) { defer fakeUpstream.Close() tp := &stubTokenProvider{baseURL: fakeUpstream.URL} - uc := upstream.NewClient(tp, nil) + uc := upstream.NewClient(tp, nil, false) h := &Handler{ upstream: uc, modelsCache: models.NewCache(uc, 5*time.Minute), From 25e660e3c3d398769d15d1442492ad5a85b78970 Mon Sep 17 00:00:00 2001 From: Wu Haotian Date: Fri, 26 Jun 2026 22:03:11 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20copilot=20options=20API=20?= =?UTF-8?q?=E2=80=94=20drop=20-1m=20upgrade=20hack,=20forward=20reasoning?= =?UTF-8?q?=5Feffort=20verbatim=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor!: remove model auto-upgrade logic The upgradeModel() helper rewrote base Claude model ids (e.g. claude-opus-4.7) to extended variants like -1m-internal / -1m when they appeared in the upstream /models list. The modern Copilot model catalog no longer ships those suffixed ids — base models carry the full context window directly via capabilities.limits — so the rewrite is solving a problem that no longer exists and was hiding the actual model id from callers. Removes the helper, the COPILOT2API_NO_MODEL_UPGRADE env var, the noModelUpgrade flag threaded through NewHandler, the related tests, and the README/CHANGELOG references. BREAKING CHANGE: requests for a base model id are no longer silently rewritten. Pass the variant id explicitly (e.g. claude-opus-4.6-1m on older accounts that still expose it) if you want a different model. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: forward reasoning_effort and output_config.effort verbatim Copilot's modern /models listing declares per-model accepted effort values in capabilities.supports.reasoning_effort (e.g. xhigh on GPT-5, minimal on Gemini Flash, max on Anthropic). The previous request plumbing only knew the legacy thinking_budget numeric and bucketed it into low|medium|high at 8k/16k thresholds, so modern enum values were either dropped or flattened. - Add ReasoningEffort *string to OpenAIChatCompletionsRequest. Accept any string and forward to upstream; per-model validation is upstream's job (the supported enum varies per model). - Both proxy smart-route converters now forward the effort string directly instead of round-tripping through ThinkingBudget. Legacy thinking_budget remains as a fallback when no effort is supplied. - Anthropic→OpenAI bridge (ConvertAnthropicToOpenAI) now honors output_config.effort with priority over thinking.budget_tokens. - Stop flattening output_config.effort: "max" → "high" in the Responses path. max (and xhigh) are first-class effort values per the current Anthropic SDK and Copilot model capabilities. Verified end-to-end against the live Copilot upstream: pre-fix smart-routed bodies for gpt-5.5 with reasoning_effort low/high/xhigh were identical (field dropped); post-fix the upstream body contains reasoning: {effort, summary: "detailed"} and completion tokens scale 197→248→276 with effort. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++- README.md | 7 --- anthropic/convert.go | 9 +++- anthropic/convert_test.go | 59 +++++++++++++++++++++++ anthropic/handler.go | 23 ++++----- anthropic/models.go | 33 +++---------- anthropic/models_test.go | 29 ------------ anthropic/responses_convert.go | 9 ++-- anthropic/responses_convert_test.go | 3 +- anthropic/types.go | 2 +- internal/types/openai.go | 5 ++ main.go | 8 +--- proxy/convert.go | 21 ++++++--- proxy/convert_test.go | 73 +++++++++++++++++++++++------ 14 files changed, 178 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c537502..3cb5e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ ### Features - Add local amp search support (`webSearch2`) using the Copilot Responses API with `web_search` tool (`gpt-5-mini` by default), and page extraction (`extractWebPageContent`) via Jina Reader, so amp CLI web search works without a paid ampcode.com account -- Auto-upgrade models to the best available variant (e.g. `claude-opus-4.7` → `claude-opus-4.7-1m-internal`) based on upstream model list, enabling features like `effort: high` that require extended variants +- Forward `reasoning_effort` verbatim on OpenAI `/v1/chat/completions` requests instead of dropping it during smart-routing to `/responses`. Values like `xhigh`, `minimal`, and `none` now reach the model — accept any string and let upstream validate against the per-model `capabilities.supports.reasoning_effort` enum +- Honor `output_config.effort` on Anthropic `/v1/messages` requests routed to OpenAI Chat Completions, in addition to the existing Responses path + +### Bug Fixes + +- Stop flattening Anthropic `output_config.effort: "max"` to `"high"` when routing to OpenAI Responses — `max` (and `xhigh`) are first-class effort values per current Anthropic SDK and Copilot model capabilities +- Stop bucketing OpenAI Responses `reasoning.effort` into the lossy `thinking_budget` numeric when smart-routing to `/chat/completions`; forward the effort string directly + +### Breaking Changes + +- Remove model auto-upgrade and the `COPILOT2API_NO_MODEL_UPGRADE` env var. Requests for a base model id (e.g. `claude-opus-4.7`) are no longer silently rewritten to `-1m-internal` / `-1m` variants — pass the variant id explicitly if you want it ## [0.3.1] - 2026-04-26 diff --git a/README.md b/README.md index 2239ef7..620db8d 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,6 @@ Add to `~/.claude/settings.json`: } ``` -### Model Auto-Upgrade - -copilot2api automatically upgrades models to the best available variant in the upstream model list. For example, if `claude-opus-4.7-1m-internal` is available, a request for `claude-opus-4.7` will automatically use it. This enables features like 1M context windows and `effort: high` that are only supported by extended variants. - -The upgrade priority is: `-1m-internal` > `-1m` > base model. - ## Usage with Codex Add to `~/.codex/config.toml`: @@ -268,7 +262,6 @@ Environment variables are used as defaults when flags are not provided: | `COPILOT2API_PORT` | Server port | `7777` | | `COPILOT2API_TOKEN_DIR` | Token storage directory | `~/.config/copilot2api` | | `COPILOT2API_DEBUG` | Enable debug logging (`true`/`false`, `1`/`0`) | `false` | -| `COPILOT2API_NO_MODEL_UPGRADE` | Disable model auto-upgrade (`true`/`false`, `1`/`0`) | `false` | | `JINA_API_KEY` | Jina API key for amp page extraction (optional) | — | CLI flags take precedence over environment variables. diff --git a/anthropic/convert.go b/anthropic/convert.go index c100e57..78d6263 100644 --- a/anthropic/convert.go +++ b/anthropic/convert.go @@ -22,8 +22,13 @@ func ConvertAnthropicToOpenAI(req AnthropicMessagesRequest) (OpenAIChatCompletio openAIReq.StreamOptions = &OpenAIStreamOptions{IncludeUsage: true} } - // Map thinking configuration - if req.Thinking != nil && req.Thinking.BudgetTokens != nil { + // Reasoning effort: prefer output_config.effort (modern Anthropic enum) + // over thinking.budget_tokens (legacy numeric). Forward effort verbatim; + // fall back to bucketing the budget when only the legacy field is set. + if req.OutputConfig != nil && req.OutputConfig.Effort != "" { + effort := req.OutputConfig.Effort + openAIReq.ReasoningEffort = &effort + } else if req.Thinking != nil && req.Thinking.BudgetTokens != nil { openAIReq.ThinkingBudget = req.Thinking.BudgetTokens } diff --git a/anthropic/convert_test.go b/anthropic/convert_test.go index 7666504..3fab08a 100644 --- a/anthropic/convert_test.go +++ b/anthropic/convert_test.go @@ -484,6 +484,65 @@ func floatPtr(f float64) *float64 { return &f } +func TestConvertAnthropicToOpenAI_OutputConfigEffort(t *testing.T) { + // output_config.effort takes priority over thinking.budget_tokens and + // is forwarded verbatim to ReasoningEffort — no lossy bucketing. + budget := 4000 + req := AnthropicMessagesRequest{ + Model: "claude-sonnet-4.6", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}}, + }, + OutputConfig: &AnthropicOutputConfig{Effort: "xhigh"}, + Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget}, + } + + openAIReq, err := ConvertAnthropicToOpenAI(req) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + if openAIReq.ReasoningEffort == nil { + t.Fatal("ReasoningEffort should not be nil when output_config.effort is set") + } + if *openAIReq.ReasoningEffort != "xhigh" { + t.Errorf("ReasoningEffort = %q, want xhigh", *openAIReq.ReasoningEffort) + } + if openAIReq.ThinkingBudget != nil { + t.Errorf("ThinkingBudget should not be set when effort wins, got %d", *openAIReq.ThinkingBudget) + } +} + +func TestConvertAnthropicToOpenAI_ThinkingBudgetFallback(t *testing.T) { + // When only legacy thinking.budget_tokens is present, it still flows + // through as ThinkingBudget — no effort string is invented. + budget := 8000 + req := AnthropicMessagesRequest{ + Model: "claude-sonnet-4.6", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}}, + }, + Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget}, + } + + openAIReq, err := ConvertAnthropicToOpenAI(req) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + if openAIReq.ReasoningEffort != nil { + t.Errorf("ReasoningEffort should be nil when only budget is set, got %q", *openAIReq.ReasoningEffort) + } + if openAIReq.ThinkingBudget == nil { + t.Fatal("ThinkingBudget should pass through") + } + if *openAIReq.ThinkingBudget != 8000 { + t.Errorf("ThinkingBudget = %d, want 8000", *openAIReq.ThinkingBudget) + } +} + func TestConvertOpenAIToAnthropic_SplitChoices(t *testing.T) { // Copilot API returns split choices for Claude models: // text in choice 0, tool_calls in choice 1 diff --git a/anthropic/handler.go b/anthropic/handler.go index c9af710..c40df58 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -19,18 +19,16 @@ import ( // Handler handles Anthropic Messages API requests type Handler struct { - upstream *upstream.Client - models *models.Cache - noModelUpgrade bool + upstream *upstream.Client + models *models.Cache } // NewHandler creates a new Anthropic handler. // The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, noModelUpgrade bool, debug bool) *Handler { +func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), - models: mc, - noModelUpgrade: noModelUpgrade, + upstream: upstream.NewClient(authClient, transport, debug), + models: mc, } } @@ -77,12 +75,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Info("anthropic request", "endpoint", "/v1/messages", "model", anthropicReq.Model, "stream", anthropicReq.Stream, "messages", len(anthropicReq.Messages), "route", route, "duration_ms", time.Since(start).Milliseconds()) }() - upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade) - modelUpgraded := upgradedModel != anthropicReq.Model - if modelUpgraded { - slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) - anthropicReq.Model = upgradedModel - } + modelInfo, capabilityFetchFailed := h.getModelInfo(r.Context(), anthropicReq.Model) if modelSupportsEndpoint(modelInfo, "/v1/messages") { route = "native" @@ -92,8 +85,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Only re-encode the body for native passthrough (the only path that // sends raw reqBody). Responses and Chat Completions paths use the // parsed struct, so they skip this JSON round-trip. - if modelChanged || modelUpgraded || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { - newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged || modelUpgraded) + if modelChanged || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { + newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged) if err != nil { WriteAnthropicError(w, http.StatusBadRequest, AnthropicErrorTypeInvalidRequest, fmt.Sprintf("Invalid JSON: %v", err)) return diff --git a/anthropic/models.go b/anthropic/models.go index 6e257ab..ae15bcb 100644 --- a/anthropic/models.go +++ b/anthropic/models.go @@ -53,37 +53,16 @@ func resolveModelAlias(modelID string) string { return modelID } -// modelUpgradeSuffixes lists suffixes to try (in order) when upgrading a model -// to the best available variant. The first match wins. -var modelUpgradeSuffixes = []string{"-1m-internal", "-1m"} - -// upgradeModel returns the best available variant of modelID by checking for -// known suffixes in the upstream model list. Returns the original if no better variant exists. -func upgradeModel(modelID string, available map[string]*models.Info) string { - for _, suffix := range modelUpgradeSuffixes { - candidate := modelID + suffix - if _, ok := available[candidate]; ok { - slog.Debug("auto-upgraded model", "from", modelID, "to", candidate) - return candidate - } - } - return modelID -} - -// getModelInfoWithUpgrade fetches model info and auto-upgrades the model -// to the best available variant (e.g. appending "-1m-internal" if available). -// Set skipUpgrade to true to disable auto-upgrade. -func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string, skipUpgrade bool) (string, *models.Info, bool) { +// getModelInfo fetches model info for the given model ID. The returned bool +// reports whether the upstream models fetch failed (in which case callers +// should fall back to a default route). +func (h *Handler) getModelInfo(ctx context.Context, modelID string) (*models.Info, bool) { infoMap, err := h.models.GetInfo(ctx) if err != nil { slog.Error("failed to fetch models for capability detection", "error", err) - return modelID, nil, true - } - - if !skipUpgrade { - modelID = upgradeModel(modelID, infoMap) + return nil, true } - return modelID, infoMap[modelID], false + return infoMap[modelID], false } func modelSupportsEndpoint(info *models.Info, endpoint string) bool { diff --git a/anthropic/models_test.go b/anthropic/models_test.go index bd64cd7..7fb6da0 100644 --- a/anthropic/models_test.go +++ b/anthropic/models_test.go @@ -82,32 +82,3 @@ func TestResolveModelAlias(t *testing.T) { }) } } - -func TestUpgradeModel(t *testing.T) { - available := map[string]*models.Info{ - "claude-opus-4.7": {ID: "claude-opus-4.7"}, - "claude-opus-4.7-1m-internal": {ID: "claude-opus-4.7-1m-internal"}, - "claude-opus-4.6": {ID: "claude-opus-4.6"}, - "claude-opus-4.6-1m": {ID: "claude-opus-4.6-1m"}, - "claude-sonnet-4.6": {ID: "claude-sonnet-4.6"}, - } - - tests := []struct { - input string - want string - }{ - {"claude-opus-4.7", "claude-opus-4.7-1m-internal"}, - {"claude-opus-4.6", "claude-opus-4.6-1m"}, - {"claude-sonnet-4.6", "claude-sonnet-4.6"}, - {"claude-sonnet-5.0", "claude-sonnet-5.0"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := upgradeModel(tt.input, available) - if got != tt.want { - t.Errorf("upgradeModel(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} diff --git a/anthropic/responses_convert.go b/anthropic/responses_convert.go index 7a91b83..04d9a8a 100644 --- a/anthropic/responses_convert.go +++ b/anthropic/responses_convert.go @@ -8,13 +8,12 @@ import ( // resolveReasoningEffort determines the reasoning effort level for the Responses API. // OutputConfig.Effort takes priority when set; otherwise falls back to thinking budget. +// Effort strings are forwarded verbatim — the upstream model's +// capabilities.supports.reasoning_effort enum is the source of truth for what's +// accepted (e.g. xhigh on GPT-5, max on Anthropic, minimal on Gemini Flash). func resolveReasoningEffort(thinking *AnthropicThinking, outputConfig *AnthropicOutputConfig) string { if outputConfig != nil && outputConfig.Effort != "" { - effort := outputConfig.Effort - if effort == "max" { - effort = "high" // Responses API doesn't support "max" - } - return effort + return outputConfig.Effort } return thinkingEffort(thinking) } diff --git a/anthropic/responses_convert_test.go b/anthropic/responses_convert_test.go index 7fa1af8..ae8592f 100644 --- a/anthropic/responses_convert_test.go +++ b/anthropic/responses_convert_test.go @@ -90,7 +90,8 @@ func TestResolveReasoningEffort(t *testing.T) { {"output_config low", nil, effort("low"), "low"}, {"output_config medium", nil, effort("medium"), "medium"}, {"output_config high", nil, effort("high"), "high"}, - {"output_config max maps to high", nil, effort("max"), "high"}, + {"output_config max forwarded verbatim", nil, effort("max"), "max"}, + {"output_config xhigh forwarded verbatim", nil, effort("xhigh"), "xhigh"}, {"output_config overrides thinking", budget(4000), effort("high"), "high"}, {"output_config empty falls back to thinking", budget(4000), &AnthropicOutputConfig{}, "low"}, } diff --git a/anthropic/types.go b/anthropic/types.go index 11a89c4..8098e7d 100644 --- a/anthropic/types.go +++ b/anthropic/types.go @@ -34,7 +34,7 @@ type AnthropicThinking struct { // AnthropicOutputConfig represents output configuration type AnthropicOutputConfig struct { - Effort string `json:"effort,omitempty"` // "low", "medium", "high", "max" + Effort string `json:"effort,omitempty"` // "low", "medium", "high", "xhigh", "max" } // AnthropicSystem can be string or []AnthropicTextBlock diff --git a/internal/types/openai.go b/internal/types/openai.go index 67b85f1..cdd292c 100644 --- a/internal/types/openai.go +++ b/internal/types/openai.go @@ -21,6 +21,11 @@ type OpenAIChatCompletionsRequest struct { User string `json:"user,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` ThinkingBudget *int `json:"thinking_budget,omitempty"` + // ReasoningEffort is forwarded verbatim. Per-model vocabulary varies + // (see /v1/models capabilities.supports.reasoning_effort): GPT-5 series + // accepts none|minimal|low|medium|high|xhigh; Gemini Flash adds minimal + // without xhigh. Pass any string through and let upstream validate. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` ReasoningText *string `json:"reasoning_text,omitempty"` ReasoningOpaque *string `json:"reasoning_opaque,omitempty"` ResponseFormat *ResponseFormat `json:"response_format,omitempty"` diff --git a/main.go b/main.go index 9f538cb..12c395d 100644 --- a/main.go +++ b/main.go @@ -125,13 +125,7 @@ func main() { proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug) // Initialize Anthropic handler - noModelUpgrade := false - if v := os.Getenv("COPILOT2API_NO_MODEL_UPGRADE"); v != "" { - if enabled, err := strconv.ParseBool(v); err == nil { - noModelUpgrade = enabled - } - } - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, noModelUpgrade, *debug) + anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, *debug) // Initialize Gemini handler geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug) diff --git a/proxy/convert.go b/proxy/convert.go index bb503d2..f405cf4 100644 --- a/proxy/convert.go +++ b/proxy/convert.go @@ -77,8 +77,15 @@ func ConvertChatToResponsesRequest(req types.OpenAIChatCompletionsRequest) types result.MaxOutputTokens = &v } - // Thinking budget → reasoning - if req.ThinkingBudget != nil { + // Reasoning effort: prefer the modern enum string when the client sends + // it directly. Fall back to bucketing thinking_budget for older clients + // that only know the numeric field. + if req.ReasoningEffort != nil && *req.ReasoningEffort != "" { + result.Reasoning = &types.ResponseReasoning{ + Effort: *req.ReasoningEffort, + Summary: "detailed", + } + } else if req.ThinkingBudget != nil { result.Reasoning = &types.ResponseReasoning{ Effort: thinkingBudgetToEffort(*req.ThinkingBudget), Summary: "detailed", @@ -499,10 +506,12 @@ func ConvertResponsesToChatRequest(req types.ResponsesRequest) types.OpenAIChatC result.MaxTokens = &v } - // Reasoning → thinking budget - if req.Reasoning != nil { - budget := effortToThinkingBudget(req.Reasoning.Effort) - result.ThinkingBudget = &budget + // Reasoning effort: forward the enum string directly. Chat Completions + // accepts reasoning_effort natively, so we don't bucket it back into the + // lossy thinking_budget representation. + if req.Reasoning != nil && req.Reasoning.Effort != "" { + effort := req.Reasoning.Effort + result.ReasoningEffort = &effort } // text.format → response_format diff --git a/proxy/convert_test.go b/proxy/convert_test.go index f902791..b517182 100644 --- a/proxy/convert_test.go +++ b/proxy/convert_test.go @@ -219,6 +219,53 @@ func TestConvertChatToResponsesRequest_ThinkingBudget(t *testing.T) { } } +func TestConvertChatToResponsesRequest_ReasoningEffort(t *testing.T) { + // reasoning_effort is forwarded verbatim, including modern values + // (xhigh, minimal, none) that the legacy budget bucketing can't produce. + efforts := []string{"low", "medium", "high", "xhigh", "minimal", "none", "max"} + + for _, effort := range efforts { + e := effort + req := types.OpenAIChatCompletionsRequest{ + Model: "gpt-5.5", + ReasoningEffort: &e, + } + + result := ConvertChatToResponsesRequest(req) + + if result.Reasoning == nil { + t.Fatalf("effort=%q: Reasoning should not be nil", effort) + } + if result.Reasoning.Effort != effort { + t.Errorf("effort=%q: Effort = %q, want %q", effort, result.Reasoning.Effort, effort) + } + if result.Reasoning.Summary != "detailed" { + t.Errorf("effort=%q: Summary = %q, want detailed", effort, result.Reasoning.Summary) + } + } +} + +func TestConvertChatToResponsesRequest_ReasoningEffortBeatsBudget(t *testing.T) { + // When both are set, the explicit effort string wins over the legacy + // thinking_budget so clients can opt into the new vocabulary cleanly. + effort := "xhigh" + budget := 4000 + req := types.OpenAIChatCompletionsRequest{ + Model: "gpt-5.5", + ReasoningEffort: &effort, + ThinkingBudget: &budget, + } + + result := ConvertChatToResponsesRequest(req) + + if result.Reasoning == nil { + t.Fatal("Reasoning should not be nil") + } + if result.Reasoning.Effort != "xhigh" { + t.Errorf("Effort = %q, want xhigh (bucketed budget would be 'low')", result.Reasoning.Effort) + } +} + func TestConvertChatToResponsesRequest_UserMultipartContent(t *testing.T) { req := types.OpenAIChatCompletionsRequest{ Model: "gpt-4", @@ -368,30 +415,28 @@ func TestConvertResponsesToChatRequest_TemperatureZero(t *testing.T) { } func TestConvertResponsesToChatRequest_Reasoning(t *testing.T) { - tests := []struct { - effort string - budget int - }{ - {"high", 32000}, - {"medium", 12000}, - {"low", 4000}, - } + // Effort is forwarded verbatim, including values the legacy budget + // bucketing couldn't represent (xhigh, max, minimal, none). + efforts := []string{"low", "medium", "high", "xhigh", "max", "minimal", "none"} - for _, tt := range tests { + for _, effort := range efforts { req := types.ResponsesRequest{ Model: "gpt-4", Reasoning: &types.ResponseReasoning{ - Effort: tt.effort, + Effort: effort, }, } result := ConvertResponsesToChatRequest(req) - if result.ThinkingBudget == nil { - t.Fatalf("effort=%q: ThinkingBudget should not be nil", tt.effort) + if result.ReasoningEffort == nil { + t.Fatalf("effort=%q: ReasoningEffort should not be nil", effort) + } + if *result.ReasoningEffort != effort { + t.Errorf("effort=%q: ReasoningEffort = %q, want %q", effort, *result.ReasoningEffort, effort) } - if *result.ThinkingBudget != tt.budget { - t.Errorf("effort=%q: ThinkingBudget = %d, want %d", tt.effort, *result.ThinkingBudget, tt.budget) + if result.ThinkingBudget != nil { + t.Errorf("effort=%q: ThinkingBudget should not be set when forwarding effort verbatim, got %d", effort, *result.ThinkingBudget) } } } From ea230ca9a1261a06dd8bf548607c8a76b2053a82 Mon Sep 17 00:00:00 2001 From: whtsky Date: Fri, 26 Jun 2026 22:03:56 +0800 Subject: [PATCH 06/11] chore: release v0.4.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb5e4c..fabe6fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.4.0] - 2026-06-26 + ### Features - Add local amp search support (`webSearch2`) using the Copilot Responses API with `web_search` tool (`gpt-5-mini` by default), and page extraction (`extractWebPageContent`) via Jina Reader, so amp CLI web search works without a paid ampcode.com account From bfa4fc18eba53489ac658806686b8c6525c41510 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sat, 25 Jul 2026 14:04:17 +0800 Subject: [PATCH 07/11] feat: support context tiers and upstream proxies --- CHANGELOG.md | 5 + ampsearch/model.go | 5 +- anthropic/handler.go | 5 +- auth/token.go | 7 +- gemini/handler.go | 4 +- internal/models/models.go | 23 +++- internal/models/models_test.go | 54 +++++++++ internal/upstream/client.go | 77 +++++++++++-- internal/upstream/client_test.go | 189 +++++++++++++++++++++++++++++++ main.go | 23 +++- proxy/handler.go | 5 +- 11 files changed, 368 insertions(+), 29 deletions(-) create mode 100644 internal/upstream/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fabe6fa..d0cc2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Features + +- Auto-inject `contextTier: "long_context"` into upstream POST request bodies for models that support the 1M context window (detected from `max_context_window_tokens` in the `/models` response). Models that don't support long context (e.g., GPT-5 mini, Gemini, Claude Haiku 4.5) are left untouched. Configurable via `COPILOT2API_CONTEXT_TIER` env var: omit for auto-detect, set to empty string to disable, or set a value (e.g. `long_context`) to force for all models +- Support HTTP/HTTPS/SOCKS5 proxy for all upstream requests via standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` environment variables + ## [0.4.0] - 2026-06-26 ### Features diff --git a/ampsearch/model.go b/ampsearch/model.go index d3bd8f5..db1bc3c 100644 --- a/ampsearch/model.go +++ b/ampsearch/model.go @@ -22,7 +22,10 @@ func NewModelBackend(client *upstream.Client, model string) *ModelBackend { if model == "" { model = "gpt-5-mini" } - return &ModelBackend{client: client, model: model, httpClient: &http.Client{Timeout: 30 * time.Second}} + return &ModelBackend{client: client, model: model, httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + }} } func (m *ModelBackend) Search(queries []string, maxResults int) ([]SearchResult, error) { diff --git a/anthropic/handler.go b/anthropic/handler.go index c40df58..e84c1f2 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -24,10 +24,9 @@ type Handler struct { } // NewHandler creates a new Anthropic handler. -// The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { +func NewHandler(uc *upstream.Client, mc *models.Cache) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), + upstream: uc, models: mc, } } diff --git a/auth/token.go b/auth/token.go index 3c9f2eb..f6caa50 100644 --- a/auth/token.go +++ b/auth/token.go @@ -18,7 +18,10 @@ const ( ) // sharedHTTPClient is reused across the auth package to enable connection pooling. -var sharedHTTPClient = &http.Client{Timeout: 30 * time.Second} +var sharedHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} type CopilotTokenResponse struct { Token string `json:"token"` @@ -112,4 +115,4 @@ func (t *CopilotToken) IsTokenUsable() bool { // Token is considered usable if it expires in more than 5 minutes return time.Until(t.ExpiresAt) > 5*time.Minute -} \ No newline at end of file +} diff --git a/gemini/handler.go b/gemini/handler.go index 90230f0..e3ded46 100644 --- a/gemini/handler.go +++ b/gemini/handler.go @@ -25,8 +25,8 @@ type Handler struct { models *models.Cache } -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { - return &Handler{upstream: upstream.NewClient(authClient, transport, debug), models: mc} +func NewHandler(uc *upstream.Client, mc *models.Cache) *Handler { + return &Handler{upstream: uc, models: mc} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/models/models.go b/internal/models/models.go index e08b784..93df1d3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -13,10 +13,27 @@ import ( "golang.org/x/sync/singleflight" ) -// Info contains model metadata including supported endpoints. +// Info contains model metadata including supported endpoints and capabilities. type Info struct { - ID string `json:"id"` - SupportedEndpoints []string `json:"supported_endpoints"` + ID string `json:"id"` + SupportedEndpoints []string `json:"supported_endpoints"` + Capabilities *Capabilities `json:"capabilities,omitempty"` +} + +type Capabilities struct { + Limits *Limits `json:"limits,omitempty"` +} + +type Limits struct { + MaxContextWindowTokens int `json:"max_context_window_tokens"` +} + +const longContextThreshold = 500_000 + +func SupportsLongContext(info *Info) bool { + return info != nil && info.Capabilities != nil && + info.Capabilities.Limits != nil && + info.Capabilities.Limits.MaxContextWindowTokens >= longContextThreshold } // modelsListResponse is the response from the /models endpoint. diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 03e6ca8..a8bea75 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -128,3 +128,57 @@ func TestNormalizeEndpoint(t *testing.T) { }) } } + +func TestSupportsLongContext(t *testing.T) { + tests := []struct { + name string + info *Info + want bool + }{ + { + name: "nil info", + info: nil, + want: false, + }, + { + name: "nil capabilities", + info: &Info{ID: "gpt-5-mini"}, + want: false, + }, + { + name: "nil limits", + info: &Info{ID: "gpt-5-mini", Capabilities: &Capabilities{}}, + want: false, + }, + { + name: "below threshold", + info: &Info{ID: "gpt-5-mini", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 200_000}, + }}, + want: false, + }, + { + name: "at threshold", + info: &Info{ID: "gpt-5.4", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 500_000}, + }}, + want: true, + }, + { + name: "above threshold (1M model)", + info: &Info{ID: "gpt-5.5", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 1_050_000}, + }}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SupportsLongContext(tt.info) + if got != tt.want { + t.Errorf("SupportsLongContext() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index ba2f94c..2890673 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -25,16 +25,24 @@ type TokenProvider interface { // Client makes requests to the upstream Copilot API. type Client struct { - TokenProvider TokenProvider - HTTPClient *http.Client - Debug bool + TokenProvider TokenProvider + HTTPClient *http.Client + Debug bool + DefaultContextTier string + // LongContextChecker gates contextTier injection per model. + // When non-nil, injection only happens if the checker returns true for the + // model ID extracted from the request body. When nil, injection is + // unconditional (force mode). + LongContextChecker func(modelID string) bool } // NewTransport creates a shared http.Transport suitable for upstream requests. +// Respects HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables. func NewTransport() *http.Transport { return &http.Transport{ + Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 120 * time.Second, @@ -63,11 +71,11 @@ func NewClient(tp TokenProvider, transport *http.Transport, debug bool) *Client // Request configures a single upstream request. type Request struct { - Method string - Endpoint string - Body interface{} // []byte, io.Reader, or JSON-marshalable struct; nil for no body - QueryString string // raw query string to append (without leading '?') - Stream bool // if true, returns *http.Response instead of reading body + Method string + Endpoint string + Body interface{} // []byte, io.Reader, or JSON-marshalable struct; nil for no body + QueryString string // raw query string to append (without leading '?') + Stream bool // if true, returns *http.Response instead of reading body ExtraHeaders map[string]string // additional headers to set after copilot headers } @@ -113,6 +121,11 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err upstreamURL += "?" + r.QueryString } + // Inject contextTier for POST requests with a JSON body. + if r.Method == "" || r.Method == "POST" { + bodyReader = c.injectContextTier(bodyReader) + } + // Apply timeout for non-streaming requests reqCtx := ctx var cancel context.CancelFunc @@ -195,3 +208,49 @@ func truncateStr(s string, maxLen int) string { } return s[:maxLen] + "..." } + +// injectContextTier reads the body, injects contextTier if absent, and returns +// a new reader. Returns the original reader unchanged if injection is disabled, +// the body is nil, or the body is not valid JSON. When LongContextChecker is +// set, injection only happens if the checker approves the model in the body. +func (c *Client) injectContextTier(body io.Reader) io.Reader { + if c.DefaultContextTier == "" || body == nil { + return body + } + + data, err := io.ReadAll(body) + if err != nil || len(data) == 0 { + if len(data) > 0 { + return bytes.NewReader(data) + } + return body + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return bytes.NewReader(data) + } + + if _, exists := raw["contextTier"]; exists { + return bytes.NewReader(data) + } + + if c.LongContextChecker != nil { + var modelID string + if m, ok := raw["model"]; ok { + json.Unmarshal(m, &modelID) + } + if !c.LongContextChecker(modelID) { + return bytes.NewReader(data) + } + } + + tierJSON, _ := json.Marshal(c.DefaultContextTier) + raw["contextTier"] = tierJSON + + modified, err := json.Marshal(raw) + if err != nil { + return bytes.NewReader(data) + } + return bytes.NewReader(modified) +} diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go new file mode 100644 index 0000000..26ef1ef --- /dev/null +++ b/internal/upstream/client_test.go @@ -0,0 +1,189 @@ +package upstream + +import ( + "bytes" + "encoding/json" + "io" + "testing" +) + +func TestInjectContextTier_AddsField(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + var tier string + if err := json.Unmarshal(parsed["contextTier"], &tier); err != nil { + t.Fatal("contextTier not found or not string:", err) + } + if tier != "long_context" { + t.Errorf("got %q, want %q", tier, "long_context") + } +} + +func TestInjectContextTier_PreservesExisting(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","contextTier":"default"}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + var tier string + if err := json.Unmarshal(parsed["contextTier"], &tier); err != nil { + t.Fatal(err) + } + if tier != "default" { + t.Errorf("got %q, want %q (should not override)", tier, "default") + } +} + +func TestInjectContextTier_DisabledWhenEmpty(t *testing.T) { + c := &Client{DefaultContextTier: ""} + body := []byte(`{"model":"gpt-4o"}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should not be injected when DefaultContextTier is empty") + } +} + +func TestInjectContextTier_NilBody(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + result := c.injectContextTier(nil) + if result != nil { + t.Error("expected nil for nil input") + } +} + +func TestInjectContextTier_InvalidJSON(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`not json at all`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + if string(data) != "not json at all" { + t.Errorf("invalid JSON should pass through unchanged, got %q", string(data)) + } +} + +func TestInjectContextTier_PreservesOtherFields(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"max_tokens":100}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + + if _, ok := parsed["model"]; !ok { + t.Error("model field lost") + } + if _, ok := parsed["messages"]; !ok { + t.Error("messages field lost") + } + if _, ok := parsed["max_tokens"]; !ok { + t.Error("max_tokens field lost") + } + if _, ok := parsed["contextTier"]; !ok { + t.Error("contextTier not added") + } +} + +func TestInjectContextTier_CheckerApproves(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID == "gpt-5.5" + }, + } + body := []byte(`{"model":"gpt-5.5","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; !exists { + t.Error("contextTier should be injected for approved model") + } +} + +func TestInjectContextTier_CheckerRejects(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID == "gpt-5.5" + }, + } + body := []byte(`{"model":"gpt-5-mini","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should NOT be injected for rejected model") + } +} + +func TestInjectContextTier_CheckerNoModelField(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID != "" + }, + } + body := []byte(`{"messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should NOT be injected when no model field and checker rejects empty") + } +} + +func TestInjectContextTier_NilCheckerForceMode(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"any-model","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; !exists { + t.Error("contextTier should be injected unconditionally when checker is nil (force mode)") + } +} diff --git a/main.go b/main.go index 12c395d..f60ca94 100644 --- a/main.go +++ b/main.go @@ -34,12 +34,9 @@ func main() { tokenDir = flag.String("token-dir", "", "Token storage directory (env: COPILOT2API_TOKEN_DIR, default: ~/.config/copilot2api)") showVersion = flag.Bool("version", false, "Show version and exit") debug = flag.Bool("debug", false, "Enable debug logging (env: COPILOT2API_DEBUG)") - ) flag.Parse() - - // Apply debug env var if !*debug { if v := os.Getenv("COPILOT2API_DEBUG"); v != "" { @@ -121,14 +118,28 @@ func main() { upstreamClient := upstream.NewClient(authClient, transport, *debug) modelsCache := models.NewCache(upstreamClient, 5*time.Minute) + // Context tier: auto-detect per model by default, env var for force/disable. + if v, ok := os.LookupEnv("COPILOT2API_CONTEXT_TIER"); ok { + upstreamClient.DefaultContextTier = v + } else { + upstreamClient.DefaultContextTier = "long_context" + upstreamClient.LongContextChecker = func(modelID string) bool { + infoMap, err := modelsCache.GetInfo(context.Background()) + if err != nil { + return false + } + return models.SupportsLongContext(infoMap[modelID]) + } + } + // Initialize proxy handler - proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug) + proxyHandler := proxy.NewHandler(upstreamClient, authClient, modelsCache) // Initialize Anthropic handler - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, *debug) + anthropicHandler := anthropic.NewHandler(upstreamClient, modelsCache) // Initialize Gemini handler - geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug) + geminiHandler := gemini.NewHandler(upstreamClient, modelsCache) // Set up routes mux := http.NewServeMux() diff --git a/proxy/handler.go b/proxy/handler.go index 5bdbff4..05f0709 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -24,10 +24,9 @@ type Handler struct { } // NewHandler creates a new proxy handler. -// The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient *auth.Client, transport *http.Transport, mc *models.Cache, debug bool) *Handler { +func NewHandler(uc *upstream.Client, authClient *auth.Client, mc *models.Cache) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), + upstream: uc, authClient: authClient, modelsCache: mc, } From 8bc499cd067a6aa393624c2952df19b97069067d Mon Sep 17 00:00:00 2001 From: whtsky Date: Sat, 25 Jul 2026 14:22:22 +0800 Subject: [PATCH 08/11] fix: handle Copilot billing model catalog responses --- CHANGELOG.md | 4 + auth/client.go | 15 ++++ internal/copilot/headers.go | 21 ++++- internal/models/models.go | 57 +++++++++++-- internal/models/models_test.go | 151 ++++++++++++++++++++++++++++++++- internal/upstream/client.go | 38 ++++++++- proxy/handler_test.go | 8 +- 7 files changed, 278 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cc2ba..a57656a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Auto-inject `contextTier: "long_context"` into upstream POST request bodies for models that support the 1M context window (detected from `max_context_window_tokens` in the `/models` response). Models that don't support long context (e.g., GPT-5 mini, Gemini, Claude Haiku 4.5) are left untouched. Configurable via `COPILOT2API_CONTEXT_TIER` env var: omit for auto-detect, set to empty string to disable, or set a value (e.g. `long_context`) to force for all models - Support HTTP/HTTPS/SOCKS5 proxy for all upstream requests via standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` environment variables +### Bug Fixes + +- Keep `/v1/models` compatible with Copilot's usage-based billing transition by using current model-access headers and retrying with the developer CLI catalog identity when the upstream returns the billing update notice + ## [0.4.0] - 2026-06-26 ### Features diff --git a/auth/client.go b/auth/client.go index 2985708..7b8199b 100644 --- a/auth/client.go +++ b/auth/client.go @@ -94,6 +94,21 @@ func (c *Client) GetToken(ctx context.Context) (string, error) { return tok.Token, nil } +// GetGitHubToken returns the stored long-lived GitHub OAuth token. It is used +// only for Copilot model-catalog compatibility fallback requests. +func (c *Client) GetGitHubToken(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + c.mu.RLock() + defer c.mu.RUnlock() + if c.creds.GitHubToken == "" { + return "", fmt.Errorf("no GitHub token available") + } + return c.creds.GitHubToken, nil +} + // GetBaseURL returns the base URL for API calls func (c *Client) GetBaseURL() string { c.mu.RLock() diff --git a/internal/copilot/headers.go b/internal/copilot/headers.go index 5efbe10..2b238a0 100644 --- a/internal/copilot/headers.go +++ b/internal/copilot/headers.go @@ -11,9 +11,10 @@ import ( // Exported constants for User-Agent and version headers. const ( - CopilotUserAgent = "GitHubCopilotChat/0.39.0" - EditorVersion = "vscode/1.111.0" - EditorPluginVersion = "copilot-chat/0.39.0" + CopilotUserAgent = "GitHubCopilotChat/0.58.0" + EditorVersion = "vscode/1.120.0" + EditorPluginVersion = "copilot-chat/0.58.0" + CopilotAPIVersion = "2026-06-01" ) // AddHeaders adds required Copilot headers to the request @@ -25,7 +26,7 @@ func AddHeaders(req *http.Request, token string) { req.Header.Set("Copilot-Integration-Id", "vscode-chat") req.Header.Set("Openai-Intent", "conversation-agent") req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Github-Api-Version", "2025-04-01") + req.Header.Set("X-Github-Api-Version", CopilotAPIVersion) // Generate request ID if not present if req.Header.Get("X-Request-Id") == "" { @@ -33,6 +34,18 @@ func AddHeaders(req *http.Request, token string) { } } +// AddModelAccessHeaders adds the headers used by Copilot's model catalog +// endpoint. The catalog is a metadata request, not a conversation request. +func AddModelAccessHeaders(req *http.Request, token string, integrationID string) { + AddHeaders(req, token) + req.Header.Set("Openai-Intent", "model-access") + req.Header.Set("X-Interaction-Type", "model-access") + req.Header.Del("Content-Type") + if integrationID != "" { + req.Header.Set("Copilot-Integration-Id", integrationID) + } +} + // GenerateRequestID generates a unique request ID using crypto/rand func GenerateRequestID() string { b := make([]byte, 16) diff --git a/internal/models/models.go b/internal/models/models.go index 93df1d3..07a35a7 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -3,6 +3,7 @@ package models import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -36,6 +37,8 @@ func SupportsLongContext(info *Info) bool { info.Capabilities.Limits.MaxContextWindowTokens >= longContextThreshold } +const billingMigrationNotice = "Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information." + // modelsListResponse is the response from the /models endpoint. type modelsListResponse struct { Data []Info `json:"data"` @@ -133,18 +136,60 @@ func (c *Cache) get(ctx context.Context) ([]byte, map[string]*Info, error) { } func (c *Cache) fetch(ctx context.Context) (cacheData, error) { - _, respData, err := c.upstream.Do(ctx, upstream.Request{ - Method: "GET", - Endpoint: "/models", - }) - if err != nil { - return cacheData{}, fmt.Errorf("models request failed: %w", err) + request := upstream.Request{ + Method: "GET", + Endpoint: "/models", + ModelAccess: true, + } + _, respData, primaryErr := c.upstream.Do(ctx, request) + if primaryErr == nil { + data, err := parseModelsResponse(respData) + if err == nil { + return data, nil + } + primaryErr = err } + if !containsBillingMigrationNotice(respData, primaryErr) { + return cacheData{}, fmt.Errorf("models request failed: %w", primaryErr) + } + + // Some accounts still reject the standard model-access identity during the + // billing migration. Match the Copilot developer CLI's fallback: retry the + // catalog with the GitHub OAuth token and its integration identity. + slog.Debug("standard models request returned the billing migration notice, trying developer CLI fallback") + _, fallbackData, fallbackErr := c.upstream.DoModelAccessWithGitHubToken(ctx, request) + if fallbackErr == nil { + data, err := parseModelsResponse(fallbackData) + if err == nil { + return data, nil + } + fallbackErr = err + } + + return cacheData{}, fmt.Errorf( + "models request failed: %w", + errors.Join(primaryErr, fmt.Errorf("developer CLI fallback failed: %w", fallbackErr)), + ) +} + +func containsBillingMigrationNotice(respData []byte, err error) bool { + if strings.Contains(string(respData), billingMigrationNotice) { + return true + } + + var upstreamErr *upstream.UpstreamError + return errors.As(err, &upstreamErr) && strings.Contains(string(upstreamErr.Body), billingMigrationNotice) +} + +func parseModelsResponse(respData []byte) (cacheData, error) { var modelsResp modelsListResponse if err := json.Unmarshal(respData, &modelsResp); err != nil { return cacheData{}, fmt.Errorf("failed to parse models response: %w", err) } + if modelsResp.Data == nil { + return cacheData{}, fmt.Errorf("models response missing data array") + } parsed := make(map[string]*Info, len(modelsResp.Data)) for i := range modelsResp.Data { diff --git a/internal/models/models_test.go b/internal/models/models_test.go index a8bea75..edd816b 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -1,6 +1,16 @@ package models -import "testing" +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/whtsky/copilot2api/internal/upstream" +) func TestPickEndpoint(t *testing.T) { tests := []struct { @@ -182,3 +192,142 @@ func TestSupportsLongContext(t *testing.T) { }) } } + +func TestCacheUsesModelAccessAndDeveloperCLIFallback(t *testing.T) { + const billingNotice = `"Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information."` + + type requestHeaders struct { + authorization string + integrationID string + intent string + interaction string + contentType string + } + var requests []requestHeaders + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, requestHeaders{ + authorization: r.Header.Get("Authorization"), + integrationID: r.Header.Get("Copilot-Integration-Id"), + intent: r.Header.Get("Openai-Intent"), + interaction: r.Header.Get("X-Interaction-Type"), + contentType: r.Header.Get("Content-Type"), + }) + w.Header().Set("Content-Type", "application/json") + if len(requests) == 1 { + _, _ = w.Write([]byte(billingNotice)) + return + } + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"gpt-5","supported_endpoints":["/responses"]}]}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + raw, err := cache.GetRaw(context.Background()) + if err != nil { + t.Fatalf("GetRaw() error = %v", err) + } + if len(requests) != 2 { + t.Fatalf("expected primary request plus fallback, got %d requests", len(requests)) + } + + if got := requests[0]; got.authorization != "Bearer copilot-token" || + got.integrationID != "vscode-chat" || got.intent != "model-access" || + got.interaction != "model-access" || got.contentType != "" { + t.Errorf("primary model-access headers = %+v", got) + } + if got := requests[1]; got.authorization != "Bearer github-token" || + got.integrationID != "copilot-developer-cli" || got.intent != "model-access" || + got.interaction != "model-access" || got.contentType != "" { + t.Errorf("developer CLI fallback headers = %+v", got) + } + + var response modelsListResponse + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("cached response is not JSON: %v", err) + } + if len(response.Data) != 1 || response.Data[0].ID != "gpt-5" { + t.Fatalf("cached models = %+v, want gpt-5", response.Data) + } + + if _, err := cache.GetRaw(context.Background()); err != nil { + t.Fatalf("cached GetRaw() error = %v", err) + } + if len(requests) != 2 { + t.Fatalf("cached catalog triggered another upstream request: %d", len(requests)) + } +} + +func TestCacheDoesNotFallbackForUnrelatedModelsFailure(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":"temporary catalog failure"}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + if _, err := cache.GetRaw(context.Background()); err == nil { + t.Fatal("GetRaw() unexpectedly succeeded for unrelated catalog failure") + } + if requestCount != 1 { + t.Fatalf("unrelated catalog failure triggered OAuth fallback: %d requests", requestCount) + } +} + +func TestCachePreservesDeveloperCLIFallbackError(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + if requestCount == 1 { + _, _ = w.Write([]byte(billingMigrationNoticeJSON)) + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"developer CLI rejected"}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + _, err := cache.GetRaw(context.Background()) + if err == nil { + t.Fatal("GetRaw() unexpectedly succeeded after fallback failure") + } + var upstreamErr *upstream.UpstreamError + if !errors.As(err, &upstreamErr) { + t.Fatalf("GetRaw() error = %v, want upstream fallback error", err) + } + if upstreamErr.StatusCode != http.StatusForbidden { + t.Fatalf("fallback status = %d, want %d", upstreamErr.StatusCode, http.StatusForbidden) + } +} + +const billingMigrationNoticeJSON = `"Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information."` + +// modelCatalogTokenProvider models the two credentials available on auth.Client +// without exposing real tokens in tests. +type modelCatalogTokenProvider struct { + baseURL string +} + +func (p *modelCatalogTokenProvider) GetToken(context.Context) (string, error) { + return "copilot-token", nil +} + +func (p *modelCatalogTokenProvider) GetGitHubToken(context.Context) (string, error) { + return "github-token", nil +} + +func (p *modelCatalogTokenProvider) GetBaseURL() string { + return p.baseURL +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 2890673..b0c38b7 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -23,6 +23,12 @@ type TokenProvider interface { GetBaseURL() string } +// GitHubTokenProvider supplies the long-lived GitHub OAuth token used by +// Copilot's developer CLI model-catalog fallback. +type GitHubTokenProvider interface { + GetGitHubToken(ctx context.Context) (string, error) +} + // Client makes requests to the upstream Copilot API. type Client struct { TokenProvider TokenProvider @@ -76,6 +82,7 @@ type Request struct { Body interface{} // []byte, io.Reader, or JSON-marshalable struct; nil for no body QueryString string // raw query string to append (without leading '?') Stream bool // if true, returns *http.Response instead of reading body + ModelAccess bool // use Copilot's metadata headers for model catalog requests ExtraHeaders map[string]string // additional headers to set after copilot headers } @@ -96,7 +103,32 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err if err != nil { return nil, nil, fmt.Errorf("failed to get valid token: %w", err) } + return c.do(ctx, r, token, "") +} + +// DoModelAccessWithGitHubToken retries the model catalog request using the +// long-lived GitHub OAuth token and the developer CLI integration identity. +// Some Copilot accounts reject the normal conversation identity for /models. +func (c *Client) DoModelAccessWithGitHubToken(ctx context.Context, r Request) (*http.Response, []byte, error) { + if r.Method != http.MethodGet || r.Endpoint != "/models" { + return nil, nil, fmt.Errorf("GitHub OAuth model-access fallback only supports GET /models") + } + + provider, ok := c.TokenProvider.(GitHubTokenProvider) + if !ok { + return nil, nil, fmt.Errorf("token provider does not expose a GitHub OAuth token") + } + token, err := provider.GetGitHubToken(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub OAuth token: %w", err) + } + + r.ModelAccess = true + return c.do(ctx, r, token, "copilot-developer-cli") +} + +func (c *Client) do(ctx context.Context, r Request, token string, integrationID string) (*http.Response, []byte, error) { // Resolve body to io.Reader var bodyReader io.Reader switch v := r.Body.(type) { @@ -144,7 +176,11 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err return nil, nil, fmt.Errorf("failed to create request: %w", err) } - copilot.AddHeaders(req, token) + if r.ModelAccess { + copilot.AddModelAccessHeaders(req, token, integrationID) + } else { + copilot.AddHeaders(req, token) + } if r.Stream { req.Header.Set("Accept", "text/event-stream") diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 5c04f8d..906ced1 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -28,13 +28,13 @@ func TestAddCopilotHeaders(t *testing.T) { // Test required headers expectedHeaders := map[string]string{ "Authorization": "Bearer " + token, - "User-Agent": copilot.CopilotUserAgent, - "Editor-Version": copilot.EditorVersion, - "Editor-Plugin-Version": copilot.EditorPluginVersion, + "User-Agent": "GitHubCopilotChat/0.58.0", + "Editor-Version": "vscode/1.120.0", + "Editor-Plugin-Version": "copilot-chat/0.58.0", "Copilot-Integration-Id": "vscode-chat", "Openai-Intent": "conversation-agent", "Content-Type": "application/json", - "X-Github-Api-Version": "2025-04-01", + "X-Github-Api-Version": "2026-06-01", } for header, expectedValue := range expectedHeaders { From 9db2d0752f7494b0466871ddc50e56f1ec74f105 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sat, 25 Jul 2026 14:33:31 +0800 Subject: [PATCH 09/11] chore: release v0.5.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57656a..11a4d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.5.0] - 2026-07-25 + ### Features - Auto-inject `contextTier: "long_context"` into upstream POST request bodies for models that support the 1M context window (detected from `max_context_window_tokens` in the `/models` response). Models that don't support long context (e.g., GPT-5 mini, Gemini, Claude Haiku 4.5) are left untouched. Configurable via `COPILOT2API_CONTEXT_TIER` env var: omit for auto-detect, set to empty string to disable, or set a value (e.g. `long_context`) to force for all models From 02c004affae4efd36b18914665873ef8b3d31d91 Mon Sep 17 00:00:00 2001 From: whtsky-playground <55422268+whtsky-playground@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:03:16 +0800 Subject: [PATCH 10/11] fix: scope context tier injection to openai endpoints (#8) Merge endpoint-aware contextTier injection fix. --- CHANGELOG.md | 4 ++++ internal/upstream/client.go | 17 +++++++++++++++-- internal/upstream/client_test.go | 23 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a4d5f..99eb78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Bug Fixes + +- Avoid injecting the Copilot-specific `contextTier` field into native Anthropic `/v1/messages` requests, which the upstream schema rejects as an extra input + ## [0.5.0] - 2026-07-25 ### Features diff --git a/internal/upstream/client.go b/internal/upstream/client.go index b0c38b7..9a1c6cf 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -10,6 +10,7 @@ import ( "log/slog" "net" "net/http" + "strings" "time" "github.com/whtsky/copilot2api/internal/copilot" @@ -153,8 +154,10 @@ func (c *Client) do(ctx context.Context, r Request, token string, integrationID upstreamURL += "?" + r.QueryString } - // Inject contextTier for POST requests with a JSON body. - if r.Method == "" || r.Method == "POST" { + // Inject contextTier only for OpenAI-compatible upstream endpoints. + // Copilot's native Anthropic /v1/messages schema rejects this + // Copilot-specific field with "Extra inputs are not permitted". + if (r.Method == "" || r.Method == "POST") && supportsContextTierEndpoint(r.Endpoint) { bodyReader = c.injectContextTier(bodyReader) } @@ -245,6 +248,16 @@ func truncateStr(s string, maxLen int) string { return s[:maxLen] + "..." } +// supportsContextTierEndpoint reports whether the endpoint accepts Copilot's +// contextTier request extension. The native Anthropic Messages endpoint does +// not: it validates the request against Anthropic's schema and rejects the +// extra top-level field. Keep this allowlist endpoint-based rather than +// model-based because a model can advertise more than one endpoint family. +func supportsContextTierEndpoint(endpoint string) bool { + endpoint = strings.TrimPrefix(endpoint, "/v1") + return endpoint == "/responses" || endpoint == "/chat/completions" +} + // injectContextTier reads the body, injects contextTier if absent, and returns // a new reader. Returns the original reader unchanged if injection is disabled, // the body is nil, or the body is not valid JSON. When LongContextChecker is diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 26ef1ef..67bc241 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -187,3 +187,26 @@ func TestInjectContextTier_NilCheckerForceMode(t *testing.T) { t.Error("contextTier should be injected unconditionally when checker is nil (force mode)") } } + +func TestSupportsContextTierEndpoint(t *testing.T) { + tests := []struct { + endpoint string + want bool + }{ + {endpoint: "/responses", want: true}, + {endpoint: "/chat/completions", want: true}, + {endpoint: "/v1/responses", want: true}, + {endpoint: "/v1/chat/completions", want: true}, + {endpoint: "/v1/messages", want: false}, + {endpoint: "/messages", want: false}, + {endpoint: "/models", want: false}, + } + + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + if got := supportsContextTierEndpoint(tt.endpoint); got != tt.want { + t.Fatalf("supportsContextTierEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.want) + } + }) + } +} From 2c7edf01c9cda2f9e5f9c40a362e7163b85625f8 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sat, 25 Jul 2026 07:03:53 +0000 Subject: [PATCH 11/11] chore: release v0.5.1 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99eb78f..09243f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.5.1] - 2026-07-25 + ### Bug Fixes - Avoid injecting the Copilot-specific `contextTier` field into native Anthropic `/v1/messages` requests, which the upstream schema rejects as an extra input