Skip to content

Commit bfd170c

Browse files
byapparovclaude
andcommitted
fix(provider): explain a missing base URL for config-only providers (#40 review)
An OpenAI-compatible provider that is not built in and is declared without `api` or `options.baseURL` now fails with ProviderBaseURLMissingError and a config fix, instead of an invalid-URL error from the SDK. ACP reports "no providers" as authRequired. The no-network tests get explicit timeouts, and the docs describe the catalog shape and the --attach context limit source. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 7edd6d9 commit bfd170c

9 files changed

Lines changed: 69 additions & 13 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
2626

2727
## Adding New Providers
2828

29-
Built-in providers and models live in the static catalog at `packages/cli/src/provider/catalog.json` (one model per line, in the `Provider.Model` shape). To add or update a built-in model, edit that file; providers that need custom loading also need an entry in `CUSTOM_LOADERS` in `packages/cli/src/provider/provider.ts`.
29+
Built-in providers and models live in the static catalog at `packages/cli/src/provider/catalog.json` (one model per line, in the `Provider.Model` shape; `api.url` is left out for providers whose SDK knows its own endpoint, and each provider also carries `npm`/`api` defaults for models added through config). To add or update a built-in model, edit that file; providers that need custom loading also need an entry in `CUSTOM_LOADERS` in `packages/cli/src/provider/provider.ts`.
3030

3131
Any other OpenAI-compatible provider does not need a code change: users define it under `provider` in `aictrl.json`.
3232

‎EVENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ For compatibility with sessions written before usage provenance was persisted, a
268268
**`context`** (context-window utilization):
269269

270270
- `used` (number) — tokens occupying the model's context window this turn: `input + cache.read + cache.write`.
271-
- `limit` (number) — the model's total context-window size in tokens, sourced from the provider registry: the built-in model catalog plus `provider` config (`model.limit.context`).
271+
- `limit` (number) — the model's total context-window size in tokens, sourced from the provider registry: the built-in model catalog plus `provider` config (`model.limit.context`). With `aictrl run --attach`, the attached server's provider registry is used instead.
272272
- `ratio` (number) — `used / limit` (≥0; may exceed 1 if usage exceeds the model's registered limit). A value approaching or exceeding 1 signals context-exhaustion risk.
273273
- `null` — emitted when the model's context limit is not known (e.g. unregistered custom endpoint), or usage is missing.
274274

‎packages/cli/src/acp/agent.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,8 +1574,11 @@ export namespace ACP {
15741574

15751575
if (specified) return specified
15761576

1577-
throw new Error(
1578-
'No providers found. Run `aictrl auth login`, set a provider API key, or define a provider under "provider" in aictrl.json.',
1577+
// Every caller is an ACP request handler that rethrows this as-is, so the client gets
1578+
// the structured authRequired response and can offer its login flow.
1579+
throw RequestError.authRequired(
1580+
undefined,
1581+
'no providers found. Run `aictrl auth login`, set a provider API key, or define a provider under "provider" in aictrl.json.',
15791582
)
15801583
}
15811584

‎packages/cli/src/cli/cmd/run.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,7 @@ export const RunCommand = cmd({
738738
// part of the prompt sent to the model and count against the context
739739
// window, just billed at the cache-write rate. Excluding them
740740
// undercounts utilization on the first turn of a conversation.
741-
// limit comes from the provider registry (the built-in catalog plus config).
741+
// limit comes from the provider registry (the built-in catalog plus config, or the attached server's).
742742
// On lookup failure (or limit===0 for custom models) buildContextWindow returns null.
743743
const contextLimit = await (args.attach
744744
? attachedContextLimit(info.providerID, info.modelID, attachedProviders())

‎packages/cli/src/cli/error.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function FormatError(input: unknown) {
1717
`Providers that are not built in must be defined under "provider" in aictrl.json`,
1818
].join("\n")
1919
}
20+
if (Provider.BaseURLMissingError.isInstance(input)) return input.message
2021
if (Provider.InitError.isInstance(input)) {
2122
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
2223
}

‎packages/cli/src/provider/provider.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,19 @@ export namespace Provider {
652652
})
653653
export type Info = z.infer<typeof Info>
654654

655+
// The OpenAI-compatible SDK has no default endpoint. A provider outside the catalog that
656+
// config declares without `api` or `options.baseURL` would otherwise fail with an invalid URL.
657+
function lacksBaseURL(model: Model, options: Record<string, any>) {
658+
if (model.api.npm !== "@ai-sdk/openai-compatible") return false
659+
return model.api.url === undefined && options["baseURL"] === undefined
660+
}
661+
662+
function baseURLMissing(providerID: string) {
663+
const error = new BaseURLMissingError({ providerID })
664+
error.message = `Provider "${providerID}" has no base URL. Set "api" or "options.baseURL" for it under "provider" in aictrl.json.`
665+
return error
666+
}
667+
655668
const state = Instance.state(async () => {
656669
using _ = log.time("state")
657670
const config = await Config.get()
@@ -1061,6 +1074,7 @@ export namespace Provider {
10611074
if (s.models.has(key)) return s.models.get(key)!
10621075

10631076
const provider = s.providers[model.providerID]
1077+
if (lacksBaseURL(model, provider.options)) throw baseURLMissing(model.providerID)
10641078
const sdk = await getSDK(model)
10651079

10661080
try {
@@ -1231,4 +1245,11 @@ export namespace Provider {
12311245
)
12321246

12331247
export const VertexConfigError = NamedError.create("VertexConfigError", z.object({ reason: z.string() }))
1248+
1249+
export const BaseURLMissingError = NamedError.create(
1250+
"ProviderBaseURLMissingError",
1251+
z.object({
1252+
providerID: z.string(),
1253+
}),
1254+
)
12341255
}

‎packages/cli/test/acp/agent-interface.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test"
22
import { ACP } from "../../src/acp/agent"
3-
import type { Agent as ACPAgent } from "@agentclientprotocol/sdk"
3+
import { RequestError, type Agent as ACPAgent } from "@agentclientprotocol/sdk"
44

55
/**
66
* Type-level test: This line will fail to compile if ACP.Agent
@@ -63,9 +63,10 @@ describe("acp default model", () => {
6363
} as unknown as Sdk
6464
}
6565

66-
test("fails with an actionable error when no provider is configured", async () => {
66+
test("asks the client to authenticate when no provider is configured", async () => {
6767
const error = await ACP.defaultModel({ sdk: sdkWith([]) }, "/tmp").catch((e: Error) => e)
68-
expect(error).toBeInstanceOf(Error)
68+
expect(error).toBeInstanceOf(RequestError)
69+
expect((error as RequestError).code).toBe(RequestError.authRequired().code)
6970
expect((error as Error).message).toContain("aictrl auth login")
7071
expect((error as Error).message).toContain('"provider" in aictrl.json')
7172
})

‎packages/cli/test/cli/no-network-startup.test.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,23 @@ async function runGuarded(entry: string, args: string[], env: Record<string, str
3232
new Response(proc.stderr).text(),
3333
proc.exited,
3434
]).finally(() => clearTimeout(timer))
35+
// A child killed by the timer never runs the guard's exit hook, so say so instead of failing on a missing report.
36+
expect(proc.signalCode, `CLI was killed before exiting\n${stderr}`).toBeNull()
3537
const report = stderr.match(/FETCH_ATTEMPTS=(.*)/)
3638
expect(report, stderr).not.toBeNull()
3739
return { stdout, stderr, exit, attempts: JSON.parse(report![1]) as string[] }
3840
}
3941

4042
describe("startup makes no network requests (#40)", () => {
41-
test.each(Object.entries(entries))("%s build: --version", async (_, entry) => {
42-
const result = await runGuarded(entry, ["--version"])
43-
expect(result.exit, result.stderr).toBe(0)
44-
expect(result.attempts).toEqual([])
45-
})
43+
test.each(Object.entries(entries))(
44+
"%s build: --version",
45+
async (_, entry) => {
46+
const result = await runGuarded(entry, ["--version"])
47+
expect(result.exit, result.stderr).toBe(0)
48+
expect(result.attempts).toEqual([])
49+
},
50+
30_000,
51+
)
4652

4753
test("models lists the built-in catalog offline", async () => {
4854
const result = await runGuarded(entries.full, ["models", "anthropic"], { ANTHROPIC_API_KEY: "test-key" })

‎packages/cli/test/provider/catalog.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ describe("provider registry", () => {
169169
})
170170
})
171171

172+
test("a config-only openai-compatible provider without a base URL fails with the config fix", async () => {
173+
await using tmp = await tmpdir({
174+
config: {
175+
provider: {
176+
groq: {
177+
options: { apiKey: "key" },
178+
models: { "llama-3.3-70b-versatile": { limit: { context: 131_072, output: 32_768 } } },
179+
},
180+
},
181+
},
182+
})
183+
await Instance.provide({
184+
directory: tmp.path,
185+
fn: async () => {
186+
const model = await Provider.getModel("groq", "llama-3.3-70b-versatile")
187+
const error = await Provider.getLanguage(model).catch((e) => e)
188+
expect(Provider.BaseURLMissingError.isInstance(error)).toBe(true)
189+
expect(FormatError(error)).toBe(
190+
`Provider "groq" has no base URL. Set "api" or "options.baseURL" for it under "provider" in aictrl.json.`,
191+
)
192+
},
193+
})
194+
})
195+
172196
test("an openai-compatible provider defined in config works without the catalog", async () => {
173197
await using tmp = await tmpdir({
174198
config: {

0 commit comments

Comments
 (0)