diff --git a/CHANGELOG.md b/CHANGELOG.md index d1270d9..e83db56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,16 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ## [Unreleased] +### Added + +- **`[Providers]` Configurable API base URL.** The extension no longer hardcodes the `opencode.ai` endpoints — it can now point at any compatible gateway via `opencodego.apiBaseUrl` (default `https://opencode.ai/zen/go/v1`) and `opencodezen.apiBaseUrl` (default `https://opencode.ai/zen/v1`). The extension derives the `/chat/completions`, `/messages`, `/responses`, `/models`, and (Go only) `/usage` routes from the configured base and applies them to every provider (normal chat, Agents window variants, inline completions, usage sync). Custom URLs are normalized and validated — non-`http(s)`, embedded credentials, query strings, and hashes are rejected and fall back to the default. Reload the window after changing the setting. Unit tests added for URL normalization and route construction. + +--- + ### Changed +- **`[Providers]` Configurable API base URL.** The extension no longer hardcodes the `opencode.ai` endpoints — it can now point at any compatible gateway via `opencodego.apiBaseUrl` (default `https://opencode.ai/zen/go/v1`) and `opencodezen.apiBaseUrl` (default `https://opencode.ai/zen/v1`). The extension derives the `/chat/completions`, `/messages`, `/responses`, `/models`, and (Go only) `/usage` routes from the configured base and applies them to every provider (normal chat, Agents window variants, inline completions, usage sync). Custom URLs are normalized and validated — non-`http(s)`, embedded credentials, query strings, and hashes are rejected and fall back to the default. Reload the window after changing the setting. Unit tests added for URL normalization and route construction. + - **`[Internal]` API keys are configured through the BYOK panel only.** The `OpenCode Go: Set API Key` / `OpenCode Zen: Set API Key` commands and the "Set / Clear API Key" menu items inside `Manage Provider` are removed — keys are entered once via **Chat: Manage Language Models → "+ Add Models"** (the native BYOK flow). `SecretStorage` is no longer a user-facing entry point; it stays as an internal per-vendor mirror (`opencodego.apiKey` / `opencodezen.apiKey`) that the BYOK resolution writes so agent-host variants and cold-start requests inherit the group key. Splitting the secret per vendor also fixes a latent collision where Go and Zen shared a single `opencodego.apiKey` and overwrote each other's key. `Refresh Models` / `Test Connection` now point at the BYOK flow when no key is configured instead of prompting for one. - **`[Internal]` Per-provider Thinking strategy classes + single config authority.** The thinking/reasoning system is refactored from one monolithic builder into a per-provider strategy (`src/thinking/`): an interface + factory (`provider.ts`), a shared base class, and one class per model family (`deepseek`, `glm`, `kimi`, `minimax`, `openai`, `qwen`, `mimo`, `fallback`). Each provider now owns its reasoning picker schema, its request-payload mapping, and whether its `reasoning_content` is surfaced as chat content. Configuration resolves from a **single authority** — the VS Code per-model configuration (model picker / Manage), with workspace settings and per-family defaults as fallbacks — instead of competing sources (workspace + modelConfiguration + a `globalState` shadow copy + defaults). The shadow copy is removed, so a thinking effort chosen for one model can no longer silently leak onto another model or override an explicit "Off". Model IDs are normalized to `effectiveModelId` (the `::sk-***` fp suffix is gone), which also stops the per-model settings group from being recreated on every pick. Request builders are split out of `extension.ts` into per-endpoint modules (`src/request/{types,schema,shared,openai,anthropic,google}.ts`). Windows tooling fixes: `scripts/lint.ts` runs npm `.cmd` shims through the shell and a new `.gitattributes` enforces LF normalization, so `npm run lint` (prettier + shellcheck) is green on Windows; `scripts/staged-lint.ts` and `isCwdInWorkspace` get the same treatment. diff --git a/README.md b/README.md index acae08a..c47f573 100644 --- a/README.md +++ b/README.md @@ -381,30 +381,32 @@ Provider diagnostics also include the VS Code/extension versions, extension host ## 🔧 Settings -| Setting | Default | Description | -| ----------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -| `opencodego.temperature` | `0.2` | Sampling temperature (`0`–`2`) | -| `opencodego.maxTokens` | `0` | Max output token override (`0` = per-model max) | -| `opencodego.maxInputTokens` | `0` | Context window override (`0` = per-model default) | -| `opencodego.debugReasoning` | `false` | Log `reasoning_content` to Output panel | -| `opencodego.requestTimeoutSeconds` | `600` | Total request timeout | -| `opencodego.streamIdleTimeoutSeconds` | `120` | Cancel if stream goes idle | -| `opencodego.showUsageStatusBar` | `true` | Show usage summary in status bar | -| `opencodego.showProviderPrefix` | `true` | Include `OpenCode Go` / `OpenCode Zen` in model names | -| `opencodego.visionProxyWholeConversation` | `false` | Vision proxy: describe the whole conversation instead of only the message with a new image (more context, more tokens) | -| `opencodego.freeOnly` | `true` | Zen: free models only. `false` = include paid | -| `opencodego.enabled` | `true` | Register the OpenCode Go provider. `false` removes it from Language Models & every picker (keys kept) | -| `opencodezen.enabled` | `true` | Register the OpenCode Zen provider. `false` removes it from Language Models & every picker (keys kept) | -| `opencodego.agentsWindow` | `true` | Expose agent-host model variants (`targetChatSessionType`) for the Agents window | -| `opencodego.showAgentModelsInManagePanel` | `false` | Show agent vendors in Manage Language Models panel | -| `opencodego.stripThinkTags` | `"auto"` | Strip `` tags (`never`/`auto`/`always`) | -| `opencodego.thinking.deepseek` | `"off"` | `off`/`low`/`medium`/`high`/`max` | -| `opencodego.thinking.glm` | `"off"` | `off`/`high`/`max` | -| `opencodego.thinking.kimi` | `"off"` | `on`/`off` | -| `opencodego.thinking.minimax` | `"off"` | `off`/`on` | -| `opencodego.thinking.mimo` | `"off"` | `off`/`low`/`medium`/`high` | -| `opencodego.thinking.qwen` | `"off"` | `auto`/`on`/`off` | -| `opencodego.thinking.qwenBudget` | `"auto"` | `auto`/`4096`/`16384`/`32768`/`81920` | +| Setting | Default | Description | +| ----------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `opencodego.apiBaseUrl` | `https://opencode.ai/zen/go/v1` | Base URL for a Go-compatible gateway; the extension appends the required API routes. Reload after changing. | +| `opencodezen.apiBaseUrl` | `https://opencode.ai/zen/v1` | Base URL for a Zen-compatible gateway; the extension appends the required API routes. Reload after changing. | +| `opencodego.temperature` | `0.2` | Sampling temperature (`0`–`2`) | +| `opencodego.maxTokens` | `0` | Max output token override (`0` = per-model max) | +| `opencodego.maxInputTokens` | `0` | Context window override (`0` = per-model default) | +| `opencodego.debugReasoning` | `false` | Log `reasoning_content` to Output panel | +| `opencodego.requestTimeoutSeconds` | `600` | Total request timeout | +| `opencodego.streamIdleTimeoutSeconds` | `120` | Cancel if stream goes idle | +| `opencodego.showUsageStatusBar` | `true` | Show usage summary in status bar | +| `opencodego.showProviderPrefix` | `true` | Include `OpenCode Go` / `OpenCode Zen` in model names | +| `opencodego.visionProxyWholeConversation` | `false` | Vision proxy: describe the whole conversation instead of only the message with a new image (more context, more tokens) | +| `opencodego.freeOnly` | `true` | Zen: free models only. `false` = include paid | +| `opencodego.enabled` | `true` | Register the OpenCode Go provider. `false` removes it from Language Models & every picker (keys kept) | +| `opencodezen.enabled` | `true` | Register the OpenCode Zen provider. `false` removes it from Language Models & every picker (keys kept) | +| `opencodego.agentsWindow` | `true` | Expose agent-host model variants (`targetChatSessionType`) for the Agents window | +| `opencodego.showAgentModelsInManagePanel` | `false` | Show agent vendors in Manage Language Models panel | +| `opencodego.stripThinkTags` | `"auto"` | Strip `` tags (`never`/`auto`/`always`) | +| `opencodego.thinking.deepseek` | `"off"` | `off`/`low`/`medium`/`high`/`max` | +| `opencodego.thinking.glm` | `"off"` | `off`/`high`/`max` | +| `opencodego.thinking.kimi` | `"off"` | `on`/`off` | +| `opencodego.thinking.minimax` | `"off"` | `off`/`on` | +| `opencodego.thinking.mimo` | `"off"` | `off`/`low`/`medium`/`high` | +| `opencodego.thinking.qwen` | `"off"` | `auto`/`on`/`off` | +| `opencodego.thinking.qwenBudget` | `"auto"` | `auto`/`4096`/`16384`/`32768`/`81920` |
📜 Full settings reference with descriptions diff --git a/package.json b/package.json index 7fe1cc7..1dd0187 100644 --- a/package.json +++ b/package.json @@ -200,6 +200,16 @@ "maximum": 2, "description": "Sampling temperature used for chat completions." }, + "opencodego.apiBaseUrl": { + "type": "string", + "default": "https://opencode.ai/zen/go/v1", + "description": "Base URL for the OpenCode Go-compatible API. The extension appends /models, /chat/completions, /messages, /responses, and /usage. Requires a window reload after changing." + }, + "opencodezen.apiBaseUrl": { + "type": "string", + "default": "https://opencode.ai/zen/v1", + "description": "Base URL for the OpenCode Zen-compatible API. The extension appends /models, /chat/completions, /messages, and /responses. Requires a window reload after changing." + }, "opencodego.maxTokens": { "type": "number", "default": 0, diff --git a/src/agentProvider.ts b/src/agentProvider.ts new file mode 100644 index 0000000..ebc7567 --- /dev/null +++ b/src/agentProvider.ts @@ -0,0 +1,20 @@ +/** Build an agent-host variant while preserving the base provider endpoints. */ +export function providerVariant( + base: T, + agentVendor: AgentVendor, + displayName: string, + baseVendor: BaseVendor, +): Omit & { + vendor: AgentVendor; + displayName: string; + isAgentVariant: true; + baseVendor: BaseVendor; +} { + return { + ...base, + vendor: agentVendor, + displayName, + isAgentVariant: true, + baseVendor, + }; +} diff --git a/src/config.ts b/src/config.ts index 8e0023e..bd31b70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,6 +45,8 @@ export const SETTING_TEMPERATURE = "temperature"; export const SETTING_MAX_TOKENS = "maxTokens"; export const SETTING_MAX_INPUT_TOKENS = "maxInputTokens"; export const SETTING_DEBUG_REASONING = "debugReasoning"; +/** Base URL setting key for the provider's OpenAI-compatible API. */ +export const SETTING_API_BASE_URL = "apiBaseUrl"; export const SETTING_REQUEST_TIMEOUT_SECONDS = "requestTimeoutSeconds"; export const SETTING_STREAM_IDLE_TIMEOUT_SECONDS = "streamIdleTimeoutSeconds"; export const SETTING_STRIP_THINK_TAGS = "stripThinkTags"; @@ -115,6 +117,33 @@ export const MODEL_METADATA_CACHE_TTL_MS = 1 * 60 * 60 * 1000; export const DEFAULT_MODEL_CONTEXT_WINDOW = 262144; export const DEFAULT_MODEL_MAX_OUTPUT_TOKENS = 65536; +// ─── Provider API endpoints ───────────────────────────────────────────────── + +/** Default OpenCode Go API base URL; can be overridden in VS Code settings. */ +export const DEFAULT_GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; +/** Default OpenCode Zen API base URL; can be overridden in VS Code settings. */ +export const DEFAULT_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; + +/** Normalize a configured API base URL, falling back when it is malformed. */ +export function normalizeApiBaseUrl(value: string, fallback: string): string { + const candidate = value.trim(); + if (!candidate) return fallback; + try { + const url = new URL(candidate); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.search || url.hash) { + return fallback; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return fallback; + } +} + +/** Append one API route to a normalized or user-supplied base URL. */ +export function appendApiPath(baseUrl: string, route: string): string { + return `${baseUrl.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`; +} + // ─── Output budget / token-estimate margins ────────────────────────────────── /** Reserve for UI rendering so the advertised output never claims the full window. */ diff --git a/src/test/agentProvider.test.ts b/src/test/agentProvider.test.ts new file mode 100644 index 0000000..a7df893 --- /dev/null +++ b/src/test/agentProvider.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { providerVariant } from "../agentProvider.js"; + +describe("providerVariant", () => { + it("preserves configured endpoints and marks the provider as an agent variant", () => { + const base = { + vendor: "opencodego", + displayName: "OpenCode Go", + baseUrl: "https://example.test/custom/v1", + modelsUrl: "https://example.test/custom/v1/models", + chatCompletionsUrl: "https://example.test/custom/v1/chat/completions", + messagesUrl: "https://example.test/custom/v1/messages", + responsesUrl: "https://example.test/custom/v1/responses", + }; + + const agent = providerVariant(base, "opencodego-agent", "OpenCode Go (Agents)", "opencodego"); + + assert.equal(agent.isAgentVariant, true); + assert.equal(agent.baseVendor, "opencodego"); + assert.equal(agent.vendor, "opencodego-agent"); + assert.equal(agent.modelsUrl, base.modelsUrl); + assert.equal(agent.chatCompletionsUrl, base.chatCompletionsUrl); + assert.equal(agent.messagesUrl, base.messagesUrl); + assert.equal(agent.responsesUrl, base.responsesUrl); + }); +}); diff --git a/src/test/config.test.ts b/src/test/config.test.ts index d680663..865d64e 100644 --- a/src/test/config.test.ts +++ b/src/test/config.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { ACTIVE_PROFILE_KEY, + appendApiPath, AGENT_HOST_BYOK_MINOR_VERSION, COMPLETION_REQUEST_TIMEOUT_MS, CONFIG_SECTION, @@ -11,6 +12,8 @@ import { DEFAULT_INLINE_PREFIX_LINES, DEFAULT_INLINE_SUFFIX_CHARS, DEFAULT_INLINE_TIMEOUT_MS, + DEFAULT_GO_API_BASE_URL, + DEFAULT_ZEN_API_BASE_URL, DEFAULT_MODEL_CONTEXT_WINDOW, DEFAULT_MODEL_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_MS, @@ -37,6 +40,7 @@ import { MODEL_METADATA_CACHE_TTL_MS, MODEL_METADATA_REVISION, MODELS_DEV_API_URL, + normalizeApiBaseUrl, PROFILES_REGISTRY_KEY, REASONING_CACHE_LIMIT, RECENT_TRANSPORT_SUMMARY_LIMIT, @@ -204,3 +208,20 @@ describe("config — references", () => { expectValue("vision prompt", DEFAULT_VISION_PROXY_PROMPT, (v) => v.length > 0, "non-empty"); }); }); + +describe("config — provider API URLs", () => { + it("builds routes from the default bases", () => { + assert.equal(appendApiPath(DEFAULT_GO_API_BASE_URL, "/chat/completions"), "https://opencode.ai/zen/go/v1/chat/completions"); + assert.equal(appendApiPath(`${DEFAULT_ZEN_API_BASE_URL}/`, "models"), "https://opencode.ai/zen/v1/models"); + }); + + it("normalizes safe custom HTTP(S) bases and rejects unsafe values", () => { + assert.equal( + normalizeApiBaseUrl("https://gateway.example.test/custom/v1///", DEFAULT_GO_API_BASE_URL), + "https://gateway.example.test/custom/v1", + ); + assert.equal(normalizeApiBaseUrl("http://localhost:8080/v1", DEFAULT_GO_API_BASE_URL), "http://localhost:8080/v1"); + assert.equal(normalizeApiBaseUrl("javascript:alert(1)", DEFAULT_GO_API_BASE_URL), DEFAULT_GO_API_BASE_URL); + assert.equal(normalizeApiBaseUrl("https://user:pass@gateway.example.test/v1", DEFAULT_GO_API_BASE_URL), DEFAULT_GO_API_BASE_URL); + }); +}); diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts index 150e425..8def934 100644 --- a/src/test/goUsageSync.test.ts +++ b/src/test/goUsageSync.test.ts @@ -55,6 +55,18 @@ test("fetchGoUsage — sends the key as Bearer to the official endpoint", async assert.equal(result.ok, true); }); +test("fetchGoUsage — accepts a custom usage endpoint", async () => { + let requestedUrl = ""; + const fetcher: typeof fetch = (input) => { + requestedUrl = typeof input === "string" ? input : ""; + return Promise.resolve(new Response(JSON.stringify(apiResponse()), { status: 200 })); + }; + + const result = await fetchGoUsage("sk-test", fetcher, undefined, "https://gateway.example.test/v1/usage"); + assert.equal(requestedUrl, "https://gateway.example.test/v1/usage"); + assert.equal(result.ok, true); +}); + test("fetchGoUsage — parses a 200 payload", async () => { const result = await fetchGoUsage("sk-test", stubFetch(200, apiResponse())); assert.ok(result.ok); diff --git a/src/usage/goUsageSync.ts b/src/usage/goUsageSync.ts index 2f0ed42..4652170 100644 --- a/src/usage/goUsageSync.ts +++ b/src/usage/goUsageSync.ts @@ -64,13 +64,14 @@ export async function fetchGoUsage( apiKey: string, fetcher: typeof fetch = fetch, timeoutMs: number = GO_USAGE_FETCH_TIMEOUT_MS, + endpointUrl: string = GO_USAGE_API_URL, ): Promise { if (!apiKey) { return { ok: false, reason: "no-key" }; } let response: Response; try { - response = await fetcher(GO_USAGE_API_URL, { + response = await fetcher(endpointUrl, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs), diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index 157c37c..abe76fe 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -96,6 +96,8 @@ export interface GoUsageTrackerOptions { resolveCodebaseWindowDays?: () => number; /** Day boundary for Today/Yesterday ("utc" default | "local"). */ resolveDayBoundary?: () => "utc" | "local"; + /** Usage endpoint derived from the configured Go API base URL. */ + resolveUsageUrl?: () => string; } interface UsageBaselinePeriod { @@ -538,7 +540,7 @@ export class GoUsageTracker { if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { return false; } - const result = await fetchGoUsage(apiKey); + const result = await fetchGoUsage(apiKey, fetch, undefined, this.options.resolveUsageUrl?.()); // Pace retries after failures too — an invalid key or unreachable // endpoint must not hammer the API on every request. this.serverUsageFetchedAt = Date.now(); @@ -549,7 +551,7 @@ export class GoUsageTracker { this.serverUsage = result.data; // Persist so the next window start can render the meters instantly. void this.context.globalState.update(this.storageKey(GO_SERVER_USAGE_KEY), result.data); - this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); + this.log?.("[go-usage] Server usage synced."); return true; }