Skip to content

Commit 57e0bda

Browse files
feat: add live provider model catalog helper
Summary: - Add a shared live provider catalog runtime for SDK-backed providers. - Route OpenAI, xAI, OpenCode Go, Chutes, DeepInfra, Venice, NVIDIA, and Vercel AI Gateway live model discovery through the shared helper. - Remove duplicated provider-local live catalog caching and harden auth marker stripping, empty live-result retries, and OpenAI custom-base-url handling. Verification: - node scripts/run-vitest.mjs extensions/openai/openai-provider.test.ts src/plugin-sdk/provider-catalog-live-runtime.test.ts src/commands/models/list.source-plan.test.ts extensions/opencode-go/index.test.ts extensions/nvidia/provider-catalog.test.ts - pnpm plugin-sdk:api:check - pnpm lint --threads=8 - pnpm run lint:extensions:bundled - pnpm run test:extensions:package-boundary:compile - pnpm check:import-cycles - pnpm exec oxfmt --check extensions/openai/openai-provider.ts extensions/openai/openai-provider.test.ts - git diff --check origin/main...HEAD - autoreview clean: no accepted/actionable findings reported - AWS Crabbox focused remote proof: run_364680d1bff8 / cbx_2456fffafe01 - Earlier same-PR AWS Crabbox live proof: run_1f05ccab368e / cbx_7375c79fcf9b Known proof gap: - Final current-code true live-provider smoke was blocked by Crabbox secret hydration, documented in the PR proof comment.
1 parent 6c35c0d commit 57e0bda

44 files changed

Lines changed: 3241 additions & 501 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
fd2aaa281db68de9db32463e87fddecfb84b6db75080d0fe47719b2b9fff3d5c plugin-sdk-api-baseline.json
2-
329a8fdad622d2ec801f99939ac6ac08685c3dd89e54aa3c2b4da4ac5580d504 plugin-sdk-api-baseline.jsonl
1+
16202a4c1ba8816643ad4cc81536c6ff9bfea38b01826d090c2195230dc85ab3 plugin-sdk-api-baseline.json
2+
a674e0fc5998b343fd1235438794c9c342fcd6e538157650109d2d30c184b7bc plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-provider-plugins.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,143 @@ API key auth, and dynamic model resolution.
205205
`openclaw onboard --acme-ai-api-key <key>` and select
206206
`acme-ai/acme-large` as their model.
207207

208+
### Live model discovery
209+
210+
If your provider exposes a `/models`-style API, keep the provider-specific
211+
endpoint and row projection in your plugin and use
212+
`openclaw/plugin-sdk/provider-catalog-live-runtime` for the shared fetch
213+
lifecycle. The helper gives you guarded HTTP fetches, provider-auth headers,
214+
structured HTTP errors, TTL caching, and static fallback behavior without
215+
putting provider policy in OpenClaw core.
216+
217+
Use `buildLiveModelProviderConfig` when the live API only tells you which
218+
provider-owned static catalog rows are currently available:
219+
220+
```typescript index.ts
221+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
222+
import {
223+
buildLiveModelProviderConfig,
224+
type LiveModelCatalogFetchGuard,
225+
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
226+
227+
const STATIC_MODELS = [
228+
{
229+
id: "acme-large",
230+
name: "Acme Large",
231+
reasoning: true,
232+
input: ["text", "image"],
233+
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
234+
contextWindow: 200000,
235+
maxTokens: 32768,
236+
},
237+
{
238+
id: "acme-small",
239+
name: "Acme Small",
240+
reasoning: false,
241+
input: ["text"],
242+
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
243+
contextWindow: 128000,
244+
maxTokens: 8192,
245+
},
246+
] as const;
247+
248+
async function buildAcmeLiveProvider(params: {
249+
apiKey: string;
250+
discoveryApiKey?: string;
251+
fetchGuard?: LiveModelCatalogFetchGuard;
252+
}) {
253+
return await buildLiveModelProviderConfig({
254+
providerId: "acme-ai",
255+
endpoint: "https://api.acme-ai.com/v1/models",
256+
providerConfig: {
257+
baseUrl: "https://api.acme-ai.com/v1",
258+
api: "openai-completions",
259+
},
260+
models: STATIC_MODELS,
261+
apiKey: params.apiKey,
262+
discoveryApiKey: params.discoveryApiKey,
263+
fetchGuard: params.fetchGuard,
264+
ttlMs: 60_000,
265+
auditContext: "acme-ai-model-discovery",
266+
});
267+
}
268+
269+
export default definePluginEntry({
270+
id: "acme-ai",
271+
name: "Acme AI",
272+
register(api) {
273+
api.registerProvider({
274+
id: "acme-ai",
275+
label: "Acme AI",
276+
catalog: {
277+
order: "simple",
278+
run: async (ctx) => {
279+
const auth = ctx.resolveProviderAuth("acme-ai");
280+
const apiKey =
281+
auth.apiKey ?? ctx.resolveProviderApiKey("acme-ai").apiKey;
282+
if (!apiKey) return null;
283+
return {
284+
provider: await buildAcmeLiveProvider({
285+
apiKey,
286+
discoveryApiKey: auth.discoveryApiKey,
287+
}),
288+
};
289+
},
290+
},
291+
staticCatalog: {
292+
order: "simple",
293+
run: async () => ({
294+
provider: {
295+
baseUrl: "https://api.acme-ai.com/v1",
296+
api: "openai-completions",
297+
models: [...STATIC_MODELS],
298+
},
299+
}),
300+
},
301+
});
302+
},
303+
});
304+
```
305+
306+
Use `getCachedLiveProviderModelRows` when the provider API returns richer
307+
metadata and the plugin needs to project rows into OpenClaw model
308+
definitions itself:
309+
310+
```typescript index.ts
311+
import {
312+
getCachedLiveProviderModelRows,
313+
LiveModelCatalogHttpError,
314+
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
315+
316+
async function discoverAcmeModels(apiKey: string) {
317+
try {
318+
const rows = await getCachedLiveProviderModelRows({
319+
providerId: "acme-ai",
320+
endpoint: "https://api.acme-ai.com/v1/models",
321+
apiKey,
322+
ttlMs: 60_000,
323+
auditContext: "acme-ai-model-discovery",
324+
});
325+
return rows
326+
.map((row) => projectAcmeModel(row))
327+
.filter((model) => model !== null);
328+
} catch (error) {
329+
if (error instanceof LiveModelCatalogHttpError) {
330+
return STATIC_MODELS;
331+
}
332+
throw error;
333+
}
334+
}
335+
```
336+
337+
`run` should stay auth-gated and return `null` when no usable credential is
338+
available. Keep an offline `staticRun` or static fallback so setup, docs,
339+
tests, and picker surfaces do not depend on live network access. Use a TTL
340+
appropriate for model-list freshness, avoid request-time filesystem polling,
341+
and pass a provider-specific `readRows` / `readModelId` only when the
342+
upstream response is not an OpenAI-compatible `{ data: [{ id, object }] }`
343+
shape.
344+
208345
If the upstream provider uses different control tokens than OpenClaw, add a
209346
small bidirectional text transform instead of replacing the stream path:
210347

@@ -292,6 +429,10 @@ API key auth, and dynamic model resolution.
292429
endpoint capability map, so native Moonshot/DashScope-style endpoints still
293430
opt in even when a plugin is using a custom provider id.
294431

432+
The live discovery examples above cover `/models`-style provider APIs. Keep
433+
that discovery inside `catalog.run`, gated on usable auth, and keep
434+
`staticRun` network-free for offline catalog generation.
435+
295436
</Step>
296437

297438
<Step title="Add dynamic model resolution">

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ and pairing-path families.
151151
| `plugin-sdk/provider-env-vars` | Provider auth env-var lookup helpers |
152152
| `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials`, OpenAI Codex auth-import helpers, deprecated `resolveOpenClawAgentDir` compatibility export |
153153
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers |
154+
| `plugin-sdk/provider-catalog-live-runtime` | Live provider model catalog helpers for guarded `/models`-style discovery: `buildLiveModelProviderConfig`, `fetchLiveProviderModelRows`, `getCachedLiveProviderModelRows`, `fetchLiveProviderModelIds`, `LiveModelCatalogHttpError`, `clearLiveCatalogCacheForTests`, model-id filtering, TTL cache, and static fallback |
154155
| `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests |
155156
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `buildManifestModelProviderConfig`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
156157
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers, provider HTTP errors, and audio transcription multipart form helpers |

extensions/chutes/models.test.ts

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ async function withLiveChutesDiscovery<T>(
4343
}
4444

4545
function createAuthEchoFetchMock() {
46-
return vi.fn().mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
47-
const auth = init?.headers?.Authorization ?? "";
46+
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
47+
const auth = readAuthorizationHeader(init);
4848
return Promise.resolve({
4949
ok: true,
5050
json: async () => ({
@@ -54,6 +54,17 @@ function createAuthEchoFetchMock() {
5454
});
5555
}
5656

57+
function readAuthorizationHeader(init?: { headers?: HeadersInit }): string {
58+
const headers = init?.headers;
59+
if (headers instanceof Headers) {
60+
return headers.get("Authorization") ?? "";
61+
}
62+
if (Array.isArray(headers)) {
63+
return headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] ?? "";
64+
}
65+
return headers?.Authorization ?? headers?.authorization ?? "";
66+
}
67+
5768
function requireChutesModel(
5869
models: Awaited<ReturnType<typeof discoverChutesModels>>,
5970
index: number,
@@ -182,8 +193,8 @@ describe("chutes-models", () => {
182193
});
183194

184195
it("discoverChutesModels retries without auth on 401", async () => {
185-
const mockFetch = vi.fn().mockImplementation((url, init) => {
186-
if (init?.headers?.Authorization === "Bearer test-token-error") {
196+
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
197+
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
187198
return Promise.resolve({
188199
ok: false,
189200
status: 401,
@@ -230,7 +241,7 @@ describe("chutes-models", () => {
230241
});
231242
});
232243

233-
it("caches fallback static catalog for non-OK responses", async () => {
244+
it("does not cache fallback static catalog for non-OK responses", async () => {
234245
const mockFetch = vi.fn().mockResolvedValue({
235246
ok: false,
236247
status: 503,
@@ -241,38 +252,36 @@ describe("chutes-models", () => {
241252
const second = await discoverChutesModels("chutes-fallback-token");
242253
expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
243254
expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
244-
expect(mockFetch).toHaveBeenCalledTimes(1);
255+
expect(mockFetch).toHaveBeenCalledTimes(2);
245256
});
246257
});
247258

248259
it("scopes discovery cache by access token", async () => {
249-
const mockFetch = vi
250-
.fn()
251-
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
252-
const auth = init?.headers?.Authorization;
253-
if (auth === "Bearer chutes-token-a") {
254-
return Promise.resolve({
255-
ok: true,
256-
json: async () => ({
257-
data: [{ id: "private/model-a" }],
258-
}),
259-
});
260-
}
261-
if (auth === "Bearer chutes-token-b") {
262-
return Promise.resolve({
263-
ok: true,
264-
json: async () => ({
265-
data: [{ id: "private/model-b" }],
266-
}),
267-
});
268-
}
260+
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
261+
const auth = readAuthorizationHeader(init);
262+
if (auth === "Bearer chutes-token-a") {
263+
return Promise.resolve({
264+
ok: true,
265+
json: async () => ({
266+
data: [{ id: "private/model-a" }],
267+
}),
268+
});
269+
}
270+
if (auth === "Bearer chutes-token-b") {
269271
return Promise.resolve({
270272
ok: true,
271273
json: async () => ({
272-
data: [{ id: "public/model" }],
274+
data: [{ id: "private/model-b" }],
273275
}),
274276
});
277+
}
278+
return Promise.resolve({
279+
ok: true,
280+
json: async () => ({
281+
data: [{ id: "public/model" }],
282+
}),
275283
});
284+
});
276285
await withLiveChutesDiscovery(mockFetch, async () => {
277286
const modelsA = await discoverChutesModels("chutes-token-a");
278287
const modelsB = await discoverChutesModels("chutes-token-b");
@@ -314,26 +323,24 @@ describe("chutes-models", () => {
314323
});
315324

316325
it("does not cache 401 fallback under the failed token key", async () => {
317-
const mockFetch = vi
318-
.fn()
319-
.mockImplementation((_url, init?: { headers?: Record<string, string> }) => {
320-
if (init?.headers?.Authorization === "Bearer failed-token") {
321-
return Promise.resolve({
322-
ok: false,
323-
status: 401,
324-
});
325-
}
326+
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
327+
if (readAuthorizationHeader(init) === "Bearer failed-token") {
326328
return Promise.resolve({
327-
ok: true,
328-
json: async () => ({
329-
data: [{ id: "public/model" }],
330-
}),
329+
ok: false,
330+
status: 401,
331331
});
332+
}
333+
return Promise.resolve({
334+
ok: true,
335+
json: async () => ({
336+
data: [{ id: "public/model" }],
337+
}),
332338
});
339+
});
333340
await withLiveChutesDiscovery(mockFetch, async () => {
334341
await discoverChutesModels("failed-token");
335342
await discoverChutesModels("failed-token");
336-
expect(mockFetch).toHaveBeenCalledTimes(4);
343+
expect(mockFetch).toHaveBeenCalledTimes(3);
337344
});
338345
});
339346
});

0 commit comments

Comments
 (0)