diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index b5d22345e2..73026cd035 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -34,7 +34,8 @@ tmux -L codevverify capture-pane -t verify -p ## Drive it - Type a prompt: `tmux -L codevverify send-keys -t verify -l "text"` then `send-keys -t verify Enter`. -- A real model is configured in `~/.config/codev/codev.jsonc` (aigateway/MiniMax); short +- A real model is configured in `~/.config/codev/codev.jsonc` (netgate/MiniMax; pre-rename + setups say aigateway); short tool-only turns ("use todowrite to ...", "create file X") finish in ~15–25s and cost ~$0. - Sidebar toggle: `C-b` or `C-x b`. Sidebar is hidden by default in this fork. - Cleanup: `tmux -L codevverify kill-server`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e91443de..499ed8da0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,25 @@ file is the human-readable summary of what changed and why. italic, inline code, code fences, list bullets, links). Also fixes a teardown race that could swallow the epilogue entirely. +### Provider setup + +- **The gateway provider is now netGate** — the SSO configure flow (and its + manual key-paste branch) writes the provider block as `provider.netgate` + with display name `netGate` instead of `provider.aigateway` / `AI Gateway`, + matching CoDev Hub's rename. Existing configs keep working: a pre-rename + `provider.aigateway` block is left in place (config updates only merge, + never delete) and both entries show in the model picker until CoDev Hub's + next config rewrite converges the file. Hub releases older than its own + rename (PR #212) can't attribute a `netgate` config as CoDev-managed. + +- **Custom providers get a name, not an id** — the "Custom provider" flow now + asks for an optional provider *name* ("Acme AI Gateway") instead of a raw + id. The id is slugged from the name (`acme-ai-gateway`, `[a-z0-9-]`, 32 + chars max) and previewed live under the field; the name is written as the + provider block's display name, so the model picker shows it instead of the + bare id. Leaving the field empty (or typing something with no usable + characters) falls back to `ai-gateway` / `AI Gateway`. + ## 1.18.4-2 ### TUI diff --git a/packages/codev-gateway/src/auth-file.ts b/packages/codev-gateway/src/auth-file.ts index 56cabf88fd..9c8e64219b 100644 --- a/packages/codev-gateway/src/auth-file.ts +++ b/packages/codev-gateway/src/auth-file.ts @@ -19,6 +19,10 @@ export interface ApiKeyCreds { apiKey: string baseUrl?: string model?: string + // Set only by the hub's manual-key path, where the user names their own + // provider. Absent ⇒ the key is SSO-issued and takes the netGate default. + providerId?: string + providerName?: string } export interface CodevConfig { @@ -45,6 +49,8 @@ interface AuthFileContents { api_key?: string base_url?: string model?: string + provider_id?: string + provider_name?: string supabase_url?: string supabase_anon_key?: string gateway_url?: string @@ -139,12 +145,18 @@ export function saveAuth(data: AuthData): void { }) } +// Writes the whole api-key block (hub saveApiKey semantics): an omitted +// provider pair *clears* it — JSON.stringify drops the undefineds — so saving +// a fresh SSO key over a hub-written manually-named key doesn't leave a stale +// identity behind for the hub to misattribute. export function saveApiKey(creds: ApiKeyCreds): void { writeAuthFile({ ...(readAuthFile() ?? {}), api_key: creds.apiKey, base_url: creds.baseUrl, model: creds.model, + provider_id: creds.providerId, + provider_name: creds.providerName, }) } @@ -155,6 +167,8 @@ export function loadApiKey(): ApiKeyCreds | null { apiKey: raw.api_key, baseUrl: raw.base_url, model: raw.model, + providerId: raw.provider_id, + providerName: raw.provider_name, } } diff --git a/packages/codev-gateway/src/config-block.ts b/packages/codev-gateway/src/config-block.ts index 5646ec50cb..c183a2f8f6 100644 --- a/packages/codev-gateway/src/config-block.ts +++ b/packages/codev-gateway/src/config-block.ts @@ -1,23 +1,26 @@ import { GATEWAY_COMPACT_RESERVED, GATEWAY_CONTEXT_WINDOW, GATEWAY_MAX_OUTPUT_TOKENS } from "./const" +import { GATEWAY_PROVIDER } from "./provider" // The exact codev.json fragment the codev hub writes for CoDev Code (hub // src/lib/configure.ts configureOpenCodeKind), so hub-managed and -// self-configured installs are indistinguishable — including the hub's -// `provider.aigateway` presence check that marks a config as CoDev-managed. -// The per-model `limit` is mandatory: without it opencode sizes the context -// window to 0 and disables auto-compaction. +// self-configured installs are indistinguishable — the hub marks a config as +// CoDev-managed by probing `provider.` against its candidate set (saved +// id, `netgate`, `ai-gateway`, legacy `aigateway`; hub PR #212), so hubs older +// than that PR cannot attribute a `netgate` config. The per-model `limit` is +// mandatory: without it opencode sizes the context window to 0 and disables +// auto-compaction. export function buildGatewayConfig(input: { apiKey: string; baseUrl: string; model: string; models?: string[] }) { const ids = [...new Set([input.model, ...(input.models ?? [])])] return { - model: `aigateway/${input.model}`, + model: `${GATEWAY_PROVIDER.id}/${input.model}`, compaction: { auto: true, reserved: GATEWAY_COMPACT_RESERVED, }, provider: { - aigateway: { + [GATEWAY_PROVIDER.id]: { npm: "@ai-sdk/openai-compatible", - name: "AI Gateway", + name: GATEWAY_PROVIDER.name, options: { baseURL: normalizeGatewayBaseUrl(input.baseUrl), apiKey: input.apiKey, @@ -46,10 +49,11 @@ export function buildGatewayConfig(input: { apiKey: string; baseUrl: string; mod // @ai-sdk/openai-compatible). The per-model limit is still mandatory — without // it opencode sizes the context window to 0 — so the gateway defaults stand in // for the unknown endpoint's real limits. -export function buildCustomProviderConfig(input: { providerID: string; baseUrl: string; models: string[] }) { +export function buildCustomProviderConfig(input: { providerID: string; name: string; baseUrl: string; models: string[] }) { return { provider: { [input.providerID]: { + name: input.name, options: { baseURL: normalizeGatewayBaseUrl(input.baseUrl), }, diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index 9c2666e8d6..37b0309fea 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -11,4 +11,11 @@ export { } from "./auth-file" export { fetchApiKey, fetchCodevConfig, fetchModels, validateApiKey } from "./backend" export { buildCustomProviderConfig, buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block" +export { + CUSTOM_FALLBACK_PROVIDER, + GATEWAY_PROVIDER, + providerFromName, + slugifyProviderName, + type ProviderIdentity, +} from "./provider" export { login, parseCallbackParams, silentSso, type LoginOptions, type OnLoginReady } from "./sso" diff --git a/packages/codev-gateway/src/provider.ts b/packages/codev-gateway/src/provider.ts new file mode 100644 index 0000000000..3d257da9b2 --- /dev/null +++ b/packages/codev-gateway/src/provider.ts @@ -0,0 +1,45 @@ +// The provider identities CoDev Code writes into the global codev.json — +// mirrors the hub's src/lib/provider.ts so hub-managed and self-configured +// installs stay indistinguishable. + +export interface ProviderIdentity { + id: string + name: string +} + +// SSO-issued gateway keys (the configure gate's SSO flow, including the +// manual-paste branch for accounts without a provisioned key). +export const GATEWAY_PROVIDER: ProviderIdentity = { + id: "netgate", + name: "netGate", +} + +// The custom-provider flow's fallback, used when the user leaves the provider +// name blank or types something that yields no usable id. +export const CUSTOM_FALLBACK_PROVIDER: ProviderIdentity = { + id: "ai-gateway", + name: "AI Gateway", +} + +const MAX_ID_LENGTH = 32 + +// The charset is deliberately [a-z0-9-]: slash-free so the top-level +// "/" string stays parseable, and TOML-bare-key safe for the hub's +// codex writer. Returns "" when the name yields nothing usable (e.g. all +// punctuation), which callers read as "fall back to the default identity". +export function slugifyProviderName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ID_LENGTH) + .replace(/-+$/, "") // second trim: the 32-char cut can land on a dash +} + +// Custom-provider flow: turn what the user typed into the pair we write. +export function providerFromName(name: string): ProviderIdentity { + const id = slugifyProviderName(name) + if (!id) return CUSTOM_FALLBACK_PROVIDER + return { id, name: name.trim() } +} diff --git a/packages/codev-gateway/test/auth-file.test.ts b/packages/codev-gateway/test/auth-file.test.ts index cc51c6e0f3..9ddb1e777e 100644 --- a/packages/codev-gateway/test/auth-file.test.ts +++ b/packages/codev-gateway/test/auth-file.test.ts @@ -32,6 +32,24 @@ describe("auth-file", () => { expect(raw.gateway_url).toBe("https://gw.test") }) + test("round-trips the provider pair and clears it on a save without one", async () => { + const { saveApiKey, loadApiKey, authFilePath } = await import("../src/auth-file") + saveApiKey({ apiKey: "sk-1", providerId: "acme-ai-gateway", providerName: "Acme AI Gateway" }) + expect(loadApiKey()).toEqual({ + apiKey: "sk-1", + providerId: "acme-ai-gateway", + providerName: "Acme AI Gateway", + }) + + // A provider-less save (a fresh SSO key) must clear the stale manual + // identity, or the hub would misattribute the new key. + saveApiKey({ apiKey: "sk-2" }) + expect(loadApiKey()).toEqual({ apiKey: "sk-2" }) + const raw = JSON.parse(readFileSync(authFilePath(), "utf-8")) + expect(raw).not.toHaveProperty("provider_id") + expect(raw).not.toHaveProperty("provider_name") + }) + test("preserves unknown fields like the hub's skillhub_cookie", async () => { const { saveApiKey, authFilePath } = await import("../src/auth-file") const { mkdirSync, writeFileSync } = await import("node:fs") diff --git a/packages/codev-gateway/test/config-block.test.ts b/packages/codev-gateway/test/config-block.test.ts index a50735fc5a..59423a4b22 100644 --- a/packages/codev-gateway/test/config-block.test.ts +++ b/packages/codev-gateway/test/config-block.test.ts @@ -22,12 +22,12 @@ describe("buildGatewayConfig", () => { models: ["MiniMax/MiniMax-M2.7", "other/model"], }) expect(config).toEqual({ - model: "aigateway/MiniMax/MiniMax-M2.7", + model: "netgate/MiniMax/MiniMax-M2.7", compaction: { auto: true, reserved: 29491 }, provider: { - aigateway: { + netgate: { npm: "@ai-sdk/openai-compatible", - name: "AI Gateway", + name: "netGate", options: { baseURL: "https://gw.test/v1", apiKey: "sk-test" }, models: { "MiniMax/MiniMax-M2.7": { name: "MiniMax/MiniMax-M2.7", limit: { context: 196608, output: 65536 } }, @@ -40,9 +40,9 @@ describe("buildGatewayConfig", () => { test("falls back to a single-model map and dedupes the default", () => { const config = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test/v1", model: "m" }) - expect(Object.keys(config.provider.aigateway.models)).toEqual(["m"]) + expect(Object.keys(config.provider.netgate.models)).toEqual(["m"]) const withDupe = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test", model: "m", models: ["m", "n"] }) - expect(Object.keys(withDupe.provider.aigateway.models)).toEqual(["m", "n"]) + expect(Object.keys(withDupe.provider.netgate.models)).toEqual(["m", "n"]) }) }) @@ -50,12 +50,14 @@ describe("buildCustomProviderConfig", () => { test("writes a keyless provider block under the given id", () => { const config = buildCustomProviderConfig({ providerID: "mygateway", + name: "My Gateway", baseUrl: "https://gw.test", models: ["m1", "m2"], }) expect(config).toEqual({ provider: { mygateway: { + name: "My Gateway", options: { baseURL: "https://gw.test/v1" }, models: { m1: { name: "m1", limit: { context: 196608, output: 65536 } }, diff --git a/packages/codev-gateway/test/provider.test.ts b/packages/codev-gateway/test/provider.test.ts new file mode 100644 index 0000000000..ed88456e25 --- /dev/null +++ b/packages/codev-gateway/test/provider.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { CUSTOM_FALLBACK_PROVIDER, GATEWAY_PROVIDER, providerFromName, slugifyProviderName } from "../src/provider" + +describe("identities", () => { + test("match the hub's built-ins", () => { + expect(GATEWAY_PROVIDER).toEqual({ id: "netgate", name: "netGate" }) + expect(CUSTOM_FALLBACK_PROVIDER).toEqual({ id: "ai-gateway", name: "AI Gateway" }) + }) +}) + +describe("slugifyProviderName", () => { + test.each([ + ["Acme AI Gateway", "acme-ai-gateway"], + ["My_Gateway 2.0", "my-gateway-2-0"], + ["a//b", "a-b"], + [" spaced out ", "spaced-out"], + ["!!!", ""], + ["", ""], + ])("%j -> %j", (input, expected) => { + expect(slugifyProviderName(input)).toBe(expected) + }) + + test("caps at 32 chars without a trailing dash", () => { + // 32nd char of the raw slug lands on the dash after "aaaa...a" (31 a's). + const slug = slugifyProviderName(`${"a".repeat(31)} ${"b".repeat(10)}`) + expect(slug).toBe("a".repeat(31)) + expect(slug.length).toBeLessThanOrEqual(32) + expect(slugifyProviderName("c".repeat(40))).toBe("c".repeat(32)) + }) +}) + +describe("providerFromName", () => { + test("slugs the id and keeps the trimmed name", () => { + expect(providerFromName(" Acme AI Gateway ")).toEqual({ id: "acme-ai-gateway", name: "Acme AI Gateway" }) + }) + + test("falls back when the name yields no usable id", () => { + expect(providerFromName("")).toEqual(CUSTOM_FALLBACK_PROVIDER) + expect(providerFromName("!!!")).toEqual(CUSTOM_FALLBACK_PROVIDER) + }) +}) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 4ca23f625f..d18ef544b0 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -27,7 +27,7 @@ import { useToast } from "../ui/toast" // Fork-only: the standalone configure flow — the in-agent equivalent of // `codevhub config`. Login with SSO (gateway key minted via the CoDev backend, -// hub-shaped provider.aigateway block written, session + key in the hub-shared +// hub-shaped provider.netgate block written, session + key in the hub-shared // ~/.codev/auth.json), set up a custom provider by API key (the extended // "Other" flow from the connect dialog), or open upstream's connect dialog for // known providers — also reachable anytime via /connect. diff --git a/packages/tui/src/component/dialog-custom-provider.tsx b/packages/tui/src/component/dialog-custom-provider.tsx index 14578b735b..35661bfa78 100644 --- a/packages/tui/src/component/dialog-custom-provider.tsx +++ b/packages/tui/src/component/dialog-custom-provider.tsx @@ -1,4 +1,11 @@ -import { buildCustomProviderConfig, fetchModels } from "@codev/gateway" +import { + buildCustomProviderConfig, + CUSTOM_FALLBACK_PROVIDER, + fetchModels, + providerFromName, + slugifyProviderName, +} from "@codev/gateway" +import { createSignal, type Accessor } from "solid-js" import { useSDK } from "../context/sdk" import { useSync } from "../context/sync" import { useTheme } from "../context/theme" @@ -6,7 +13,6 @@ import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { useToast } from "../ui/toast" import { DialogModel } from "./dialog-model" -import { normalizeCustomProviderID } from "./dialog-provider" // Fork-only: the extended custom-provider flow behind the connect dialog's // "Other" option and the configure gate's "Custom provider" entry. Upstream's @@ -28,11 +34,9 @@ export interface CustomProviderFlowContext { toast: ReturnType } -// Explicit return type: dialog-provider both calls this and is imported back -// for normalizeCustomProviderID, and the cycle breaks inference (TS7023). -export async function runCustomProviderFlow(ctx: CustomProviderFlowContext, initialProviderID?: string): Promise { - const providerID = initialProviderID ?? (await promptProviderID(ctx)) - if (!providerID) return +export async function runCustomProviderFlow(ctx: CustomProviderFlowContext) { + const provider = await promptProviderName(ctx) + if (!provider) return const apiKey = (await DialogPrompt.show(ctx.dialog, "API key", { placeholder: "sk-..." }))?.trim() if (!apiKey) return @@ -49,12 +53,17 @@ export async function runCustomProviderFlow(ctx: CustomProviderFlowContext, init // The credential is saved even when config discovery was skipped or failed — // upstream's manual codev.json path stays available either way. - await ctx.sdk.client.auth.set({ providerID, auth: { type: "api", key: apiKey } }) + await ctx.sdk.client.auth.set({ providerID: provider.id, auth: { type: "api", key: apiKey } }) if (models) { - setStatus(`Configuring ${providerID}...`) + setStatus(`Configuring ${provider.id}...`) const result = await ctx.sdk.client.global.config.update({ - config: buildCustomProviderConfig({ providerID, baseUrl: baseUrl.trim(), models }), + config: buildCustomProviderConfig({ + providerID: provider.id, + name: provider.name, + baseUrl: baseUrl.trim(), + models, + }), }) if (result.error) { ctx.toast.show({ variant: "error", message: JSON.stringify(result.error) }) @@ -66,15 +75,15 @@ export async function runCustomProviderFlow(ctx: CustomProviderFlowContext, init await ctx.sdk.client.instance.dispose() await ctx.sync.bootstrap() - if (!ctx.sync.data.provider_next.all.some((provider) => provider.id === providerID)) { + if (!ctx.sync.data.provider_next.all.some((existing) => existing.id === provider.id)) { ctx.toast.show({ variant: "info", - message: `Saved credential for ${providerID}. Configure it in codev.json to use it.`, + message: `Saved credential for ${provider.id}. Configure it in codev.json to use it.`, }) ctx.dialog.clear() return } - ctx.dialog.replace(() => ) + ctx.dialog.replace(() => ) } // Model discovery via the OpenAI-compatible /v1/models endpoint. Resolves @@ -95,19 +104,34 @@ function discoverModels( }) } -async function promptProviderID(ctx: CustomProviderFlowContext): Promise { - const value = await DialogPrompt.show(ctx.dialog, "Custom provider", { placeholder: "Provider id" }) +// Optional name prompt (parity with the hub's manual-key Provider Name field): +// the typed name is slugged into the provider id — shown live under the field — +// and a blank or unusable name falls back to ai-gateway / AI Gateway. Non-ASCII +// is stripped before slugging/storing since the name seeds a config key. +async function promptProviderName(ctx: CustomProviderFlowContext) { + const [slug, setSlug] = createSignal("") + const value = await DialogPrompt.show(ctx.dialog, "Custom provider", { + placeholder: "Provider name (optional)", + onInput: (input) => setSlug(slugifyProviderName(asciiOnly(input))), + description: () => , + }) if (value === null) return + return providerFromName(asciiOnly(value)) +} - const providerID = normalizeCustomProviderID(value) - if (providerID) return providerID +function asciiOnly(value: string): string { + return value.replace(/[^\x20-\x7E]/g, "") +} - ctx.toast.show({ - variant: "error", - message: - "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", - }) - return promptProviderID(ctx) +function ProviderNameHint(props: { slug: Accessor }) { + const { theme } = useTheme() + return ( + + {props.slug() + ? `→ id: ${props.slug()}` + : `Leave empty to use ${CUSTOM_FALLBACK_PROVIDER.name} (id: ${CUSTOM_FALLBACK_PROVIDER.id}).`} + + ) } function BaseUrlHint() {