From fcf3b06a093bd94bf18fd9623512964ee2831e1f Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 16:48:58 +0800 Subject: [PATCH 1/7] 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 --- CHANGELOG.md | 4 + README.md | 5 + ampsearch/handler.go | 148 ++++++++++++++++++++++++++++++ ampsearch/model.go | 213 +++++++++++++++++++++++++++++++++++++++++++ main.go | 8 ++ 5 files changed, 378 insertions(+) create mode 100644 ampsearch/handler.go create mode 100644 ampsearch/model.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f65d4..0a4c512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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 + ## [0.3.1] - 2026-04-26 ### Bug Fixes diff --git a/README.md b/README.md index b95e41c..7795140 100644 --- a/README.md +++ b/README.md @@ -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,7 @@ 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` | +| `JINA_API_KEY` | Jina API key for amp page extraction (optional) | — | CLI flags take precedence over environment variables. 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/main.go b/main.go index da35bd0..1ca40bb 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/whtsky/copilot2api/ampsearch" "github.com/whtsky/copilot2api/anthropic" "github.com/whtsky/copilot2api/auth" "github.com/whtsky/copilot2api/gemini" @@ -156,6 +157,13 @@ 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, "")) + mux.HandleFunc("/api/internal", func(w http.ResponseWriter, r *http.Request) { + if searchHandler.TryServe(w, r) { + return + } + ampReverseProxy.ServeHTTP(w, r) + }) 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) From dca91ba16a22bbfad9200196140b8db7270dacb4 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 17:33:22 +0800 Subject: [PATCH 2/7] 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 --- CHANGELOG.md | 1 + anthropic/handler.go | 6 +++++- anthropic/models.go | 30 ++++++++++++++++++++++++++++++ anthropic/models_test.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4c512..c537502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 diff --git a/anthropic/handler.go b/anthropic/handler.go index 244b923..992d60b 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -85,7 +85,11 @@ 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) + if upgradedModel != anthropicReq.Model { + slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) + anthropicReq.Model = upgradedModel + } if modelSupportsEndpoint(modelInfo, "/v1/messages") { route = "native" diff --git a/anthropic/models.go b/anthropic/models.go index afbd7b5..dc49281 100644 --- a/anthropic/models.go +++ b/anthropic/models.go @@ -57,6 +57,23 @@ 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 +} + // 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) @@ -70,6 +87,19 @@ func (h *Handler) getModelInfo(ctx context.Context, modelID string) (*models.Inf return infoMap[modelID], false } +// getModelInfoWithUpgrade is like getModelInfo but also auto-upgrades the model +// to the best available variant (e.g. appending "-1m-internal" if available). +func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string) (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 + } + + upgraded := upgradeModel(modelID, infoMap) + return upgraded, infoMap[upgraded], false +} + func modelSupportsEndpoint(info *models.Info, endpoint string) bool { return models.SupportsEndpoint(info, endpoint) } 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) + } + }) + } +} From 6c519a50ec621b086183aaf5c9c318eb07f6c483 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 17:38:15 +0800 Subject: [PATCH 3/7] 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 --- README.md | 7 ++++--- anthropic/handler.go | 24 ++++++++---------------- anthropic/models.go | 28 +++++++--------------------- main.go | 8 +++++++- 4 files changed, 26 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 7795140..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 @@ -268,6 +268,7 @@ 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/handler.go b/anthropic/handler.go index 992d60b..1a0af0e 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) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport), - models: mc, + upstream: upstream.NewClient(authClient, transport), + 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,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) + upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade) if upgradedModel != anthropicReq.Model { slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) anthropicReq.Model = upgradedModel diff --git a/anthropic/models.go b/anthropic/models.go index dc49281..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") @@ -74,30 +70,20 @@ func upgradeModel(modelID string, available map[string]*models.Info) 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) - - infoMap, err := h.models.GetInfo(ctx) - if err != nil { - slog.Error("failed to fetch models for capability detection", "error", err) - return nil, true - } - - return infoMap[modelID], false -} - -// getModelInfoWithUpgrade is like getModelInfo but also auto-upgrades the model +// getModelInfoWithUpgrade fetches model info and auto-upgrades the model // to the best available variant (e.g. appending "-1m-internal" if available). -func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string) (string, *models.Info, bool) { +// 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 modelID, nil, true } - upgraded := upgradeModel(modelID, infoMap) - return upgraded, infoMap[upgraded], false + if !skipUpgrade { + modelID = upgradeModel(modelID, infoMap) + } + return modelID, infoMap[modelID], false } func modelSupportsEndpoint(info *models.Info, endpoint string) bool { diff --git a/main.go b/main.go index 1ca40bb..ee02b16 100644 --- a/main.go +++ b/main.go @@ -121,7 +121,13 @@ func main() { proxyHandler := proxy.NewHandler(authClient, transport, modelsCache) // 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) // Initialize Gemini handler geminiHandler := gemini.NewHandler(authClient, transport, modelsCache) From 23322dc4337199cca0febb4341ba7b7365508dbf Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 10:26:33 +0000 Subject: [PATCH 4/7] 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. --- anthropic/handler.go | 7 ++++--- internal/upstream/client.go | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/anthropic/handler.go b/anthropic/handler.go index 1a0af0e..e31c66c 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -78,7 +78,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }() upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade) - if upgradedModel != anthropicReq.Model { + modelUpgraded := upgradedModel != anthropicReq.Model + if modelUpgraded { slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) anthropicReq.Model = upgradedModel } @@ -91,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/internal/upstream/client.go b/internal/upstream/client.go index e99158b..6e0b199 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" @@ -138,6 +139,18 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err req.Header.Set(k, v) } + // Debug log: outgoing request + 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 +162,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 +181,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] + "..." +} From c38a66e048682942df5d11e19d9c8b5600c8310a Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 10:33:47 +0000 Subject: [PATCH 5/7] perf: guard upstream request debug log with level check --- internal/upstream/client.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 6e0b199..ff70cce 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -140,15 +140,17 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err } // Debug log: outgoing request - 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)) + if slog.Default().Enabled(reqCtx, slog.LevelDebug) { + 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) } - } else { - slog.Debug("upstream request", "method", method, "url", upstreamURL) } resp, err := c.HTTPClient.Do(req) From 51e8719ad5765c12f5159e546fc3403c6250aa15 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 10:42:46 +0000 Subject: [PATCH 6/7] refactor: pass debug flag to upstream client, avoid per-request level check --- anthropic/handler.go | 4 ++-- gemini/handler.go | 4 ++-- gemini/handler_test.go | 6 +++--- internal/upstream/client.go | 6 ++++-- main.go | 8 ++++---- proxy/handler.go | 4 ++-- proxy/handler_test.go | 8 ++++---- proxy/smart_routing_test.go | 20 ++++++++++---------- 8 files changed, 31 insertions(+), 29 deletions(-) diff --git a/anthropic/handler.go b/anthropic/handler.go index e31c66c..c9af710 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -26,9 +26,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, noModelUpgrade bool) *Handler { +func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, noModelUpgrade bool, debug bool) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport), + upstream: upstream.NewClient(authClient, transport, debug), models: mc, noModelUpgrade: noModelUpgrade, } 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 ff70cce..ba2f94c 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -27,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. @@ -45,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() } @@ -56,6 +57,7 @@ func NewClient(tp TokenProvider, transport *http.Transport) *Client { // Non-streaming requests use per-request context timeouts instead. Transport: transport, }, + Debug: debug, } } @@ -140,7 +142,7 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err } // Debug log: outgoing request - if slog.Default().Enabled(reqCtx, slog.LevelDebug) { + if c.Debug { if bodyReader != nil { if br, ok := bodyReader.(*bytes.Reader); ok { rawBytes := make([]byte, br.Len()) diff --git a/main.go b/main.go index ee02b16..1a40d33 100644 --- a/main.go +++ b/main.go @@ -114,11 +114,11 @@ 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 noModelUpgrade := false @@ -127,10 +127,10 @@ func main() { noModelUpgrade = enabled } } - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, noModelUpgrade) + 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() 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 87943c4c42f5e375140b014335da618942e30320 Mon Sep 17 00:00:00 2001 From: whtsky Date: Sun, 26 Apr 2026 17:27:08 +0000 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20add=20amp=20local=20mode=20?= =?UTF-8?q?=E2=80=94=20serve=20thread=20management=20APIs=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- amplocal/amplocal_test.go | 374 +++++++++++++++++++++++++ amplocal/handler.go | 569 ++++++++++++++++++++++++++++++++++++++ amplocal/state.go | 324 ++++++++++++++++++++++ amplocal/types.go | 44 +++ main.go | 28 ++ 5 files changed, 1339 insertions(+) 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 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/main.go b/main.go index 1a40d33..9f538cb 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/whtsky/copilot2api/amplocal" "github.com/whtsky/copilot2api/ampsearch" "github.com/whtsky/copilot2api/anthropic" "github.com/whtsky/copilot2api/auth" @@ -33,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 != "" { @@ -164,12 +168,36 @@ func main() { 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)