Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/codev-gateway/src/auth-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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,
}
}

Expand Down
20 changes: 12 additions & 8 deletions packages/codev-gateway/src/config-block.ts
Original file line number Diff line number Diff line change
@@ -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.<id>` 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,
Expand Down Expand Up @@ -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),
},
Expand Down
7 changes: 7 additions & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions packages/codev-gateway/src/provider.ts
Original file line number Diff line number Diff line change
@@ -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
// "<id>/<model>" 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() }
}
18 changes: 18 additions & 0 deletions packages/codev-gateway/test/auth-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 7 additions & 5 deletions packages/codev-gateway/test/config-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -40,22 +40,24 @@ 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"])
})
})

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 } },
Expand Down
41 changes: 41 additions & 0 deletions packages/codev-gateway/test/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
2 changes: 1 addition & 1 deletion packages/tui/src/component/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading