diff --git a/.changeset/discover-provider-models.md b/.changeset/discover-provider-models.md new file mode 100644 index 000000000..b817d6c26 --- /dev/null +++ b/.changeset/discover-provider-models.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge': minor +--- + +Add `GET /settings/model-providers/{name}/discovered-models`, which asks a configured provider which models it serves. Gemini's native list reports token limits, so discovered models carry the `context_length` that context compaction relies on; other providers use the OpenAI-compatible list, which reports ids only. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 2483e5ef6..840dfdffb 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1198,6 +1198,30 @@ "properties": {}, "type": "object" }, + "DiscoveredModel": { + "additionalProperties": false, + "properties": { + "context_length": { + "description": "Maximum context window in tokens, when the provider reports one.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "max_output_tokens": { + "description": "Maximum output tokens, when the provider reports one.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "model_id": { + "description": "Upstream, provider-specific identifier sent to the provider API.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "model_id" + ], + "type": "object" + }, "DynamicSubAgentsConfig": { "default": { "enabled": true @@ -1717,6 +1741,20 @@ ], "type": "object" }, + "ListDiscoveredModelsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DiscoveredModel" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListMCPServerToolsResponse": { "properties": { "data": { @@ -8530,6 +8568,75 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/model-providers/{name}/discovered-models": { + "get": { + "description": "Asks the provider itself which models it serves, using the stored API key. Returns token limits when the provider reports them (Gemini does; the OpenAI-compatible list does not). The shipped catalog is a preset list and may lag the provider, so this is the current source of truth.", + "parameters": [ + { + "description": "Configured provider resource name, e.g. `google-gemini`.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Configured provider resource name, e.g. `google-gemini`.", + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDiscoveredModelsResponse" + } + } + }, + "description": "Models the provider reports" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "No provider is configured under this name." + }, + "501": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "This provider type has no discovery adapter." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The provider was unreachable or rejected the request." + } + }, + "summary": "List the models a configured provider reports", + "tags": [ + "Models" + ], + "x-fern-sdk-group-name": [ + "settings", + "modelProviders" + ], + "x-fern-sdk-method-name": "discovered_models" + } + }, "/api/v1/settings/sandbox-providers": { "get": { "description": "The single configured sandbox provider for this tenant. `auth.api_key` is redacted.", diff --git a/docs/openapi.json b/docs/openapi.json index 2483e5ef6..840dfdffb 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1198,6 +1198,30 @@ "properties": {}, "type": "object" }, + "DiscoveredModel": { + "additionalProperties": false, + "properties": { + "context_length": { + "description": "Maximum context window in tokens, when the provider reports one.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "max_output_tokens": { + "description": "Maximum output tokens, when the provider reports one.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "model_id": { + "description": "Upstream, provider-specific identifier sent to the provider API.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "model_id" + ], + "type": "object" + }, "DynamicSubAgentsConfig": { "default": { "enabled": true @@ -1717,6 +1741,20 @@ ], "type": "object" }, + "ListDiscoveredModelsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DiscoveredModel" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListMCPServerToolsResponse": { "properties": { "data": { @@ -8530,6 +8568,75 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/model-providers/{name}/discovered-models": { + "get": { + "description": "Asks the provider itself which models it serves, using the stored API key. Returns token limits when the provider reports them (Gemini does; the OpenAI-compatible list does not). The shipped catalog is a preset list and may lag the provider, so this is the current source of truth.", + "parameters": [ + { + "description": "Configured provider resource name, e.g. `google-gemini`.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Configured provider resource name, e.g. `google-gemini`.", + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDiscoveredModelsResponse" + } + } + }, + "description": "Models the provider reports" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "No provider is configured under this name." + }, + "501": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "This provider type has no discovery adapter." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The provider was unreachable or rejected the request." + } + }, + "summary": "List the models a configured provider reports", + "tags": [ + "Models" + ], + "x-fern-sdk-group-name": [ + "settings", + "modelProviders" + ], + "x-fern-sdk-method-name": "discovered_models" + } + }, "/api/v1/settings/sandbox-providers": { "get": { "description": "The single configured sandbox provider for this tenant. `auth.api_key` is redacted.", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 901600cf5..f391150ef 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -3302,6 +3302,69 @@ await client.settings.modelProviders.createOrUpdate({ + + + + +
client.settings.modelProviders.discoveredModels(name) -> TrueForge.ListDiscoveredModelsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Asks the provider itself which models it serves, using the stored API key. Returns token limits when the provider reports them (Gemini does; the OpenAI-compatible list does not). The shipped catalog is a preset list and may lag the provider, so this is the current source of truth. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.settings.modelProviders.discoveredModels("name"); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `string` — Configured provider resource name, e.g. `google-gemini`. + +
+
+ +
+
+ +**requestOptions:** `ModelProvidersClient.RequestOptions` + +
+
+
+
+ +
diff --git a/packages/trueforge-sdk/src/api/errors/NotImplementedError.ts b/packages/trueforge-sdk/src/api/errors/NotImplementedError.ts new file mode 100644 index 000000000..722108667 --- /dev/null +++ b/packages/trueforge-sdk/src/api/errors/NotImplementedError.ts @@ -0,0 +1,24 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../../core/index.js"; +import * as errors from "../../errors/index.js"; +import type * as TrueForge from "../index.js"; + +export class NotImplementedError extends errors.TrueForgeError { + public declare readonly body: TrueForge.RequestErrorResponse; + + constructor(body: TrueForge.RequestErrorResponse, rawResponse?: core.RawResponse) { + super({ + message: "NotImplementedError", + statusCode: 501, + body: body, + rawResponse: rawResponse, + }); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = "NotImplementedError"; + } +} diff --git a/packages/trueforge-sdk/src/api/errors/index.ts b/packages/trueforge-sdk/src/api/errors/index.ts index 5388d473c..7ca456850 100644 --- a/packages/trueforge-sdk/src/api/errors/index.ts +++ b/packages/trueforge-sdk/src/api/errors/index.ts @@ -7,6 +7,7 @@ export * from "./ForbiddenError.js"; export * from "./GoneError.js"; export * from "./InternalServerError.js"; export * from "./NotFoundError.js"; +export * from "./NotImplementedError.js"; export * from "./PreconditionFailedError.js"; export * from "./UnauthorizedError.js"; export * from "./UnprocessableEntityError.js"; diff --git a/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts b/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts index 9dadd382d..dd1aa2e24 100644 --- a/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts @@ -377,4 +377,116 @@ export class ModelProvidersClient { "/api/v1/settings/model-providers", ); } + + /** + * Asks the provider itself which models it serves, using the stored API key. Returns token limits when the provider reports them (Gemini does; the OpenAI-compatible list does not). The shipped catalog is a preset list and may lag the provider, so this is the current source of truth. + * + * @param {string} name - Configured provider resource name, e.g. `google-gemini`. + * @param {ModelProvidersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.NotFoundError} + * @throws {@link TrueForge.NotImplementedError} + * @throws {@link TrueForge.BadGatewayError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.settings.modelProviders.discoveredModels("name") + */ + public discoveredModels( + name: string, + requestOptions?: ModelProvidersClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__discoveredModels(name, requestOptions)); + } + + private async __discoveredModels( + name: string, + requestOptions?: ModelProvidersClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `api/v1/settings/model-providers/${core.url.encodePathParam(name)}/discovered-models`, + ), + method: "GET", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.ListDiscoveredModelsResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 501: + throw new TrueForge.NotImplementedError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 502: + throw new TrueForge.BadGatewayError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/api/v1/settings/model-providers/{name}/discovered-models", + ); + } } diff --git a/packages/trueforge-sdk/src/api/types/DiscoveredModel.ts b/packages/trueforge-sdk/src/api/types/DiscoveredModel.ts new file mode 100644 index 000000000..baeea71ae --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/DiscoveredModel.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface DiscoveredModel { + /** Maximum context window in tokens, when the provider reports one. */ + contextLength?: number; + /** Maximum output tokens, when the provider reports one. */ + maxOutputTokens?: number; + /** Upstream, provider-specific identifier sent to the provider API. */ + modelId: string; +} diff --git a/packages/trueforge-sdk/src/api/types/ListDiscoveredModelsResponse.ts b/packages/trueforge-sdk/src/api/types/ListDiscoveredModelsResponse.ts new file mode 100644 index 000000000..f0417ac8c --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ListDiscoveredModelsResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface ListDiscoveredModelsResponse { + data: TrueForge.DiscoveredModel[]; +} diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index c1114ffdd..021aa1482 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -50,6 +50,7 @@ export * from "./CustomModelProvider.js"; export * from "./DaytonaSandboxProviderAuth.js"; export * from "./DeleteAgentResponse.js"; export * from "./DeleteScheduleResponse.js"; +export * from "./DiscoveredModel.js"; export * from "./DynamicSubAgentsConfig.js"; export * from "./ExtendedChunkDeltaToolCall.js"; export * from "./FileContent.js"; @@ -83,6 +84,7 @@ export * from "./ListAgentsResponse.js"; export * from "./ListAvailableMcpServersResponse.js"; export * from "./ListAvailableModelsResponse.js"; export * from "./ListAvailableSkillsResponse.js"; +export * from "./ListDiscoveredModelsResponse.js"; export * from "./ListMcpServersResponse.js"; export * from "./ListMcpServerToolsResponse.js"; export * from "./ListModelProvidersResponse.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/DiscoveredModel.ts b/packages/trueforge-sdk/src/serialization/types/DiscoveredModel.ts new file mode 100644 index 000000000..a6f51aefe --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/DiscoveredModel.ts @@ -0,0 +1,22 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const DiscoveredModel: core.serialization.ObjectSchema< + serializers.DiscoveredModel.Raw, + TrueForge.DiscoveredModel +> = core.serialization.object({ + contextLength: core.serialization.property("context_length", core.serialization.number().optional()), + maxOutputTokens: core.serialization.property("max_output_tokens", core.serialization.number().optional()), + modelId: core.serialization.property("model_id", core.serialization.string()), +}); + +export declare namespace DiscoveredModel { + export interface Raw { + context_length?: number | null; + max_output_tokens?: number | null; + model_id: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/ListDiscoveredModelsResponse.ts b/packages/trueforge-sdk/src/serialization/types/ListDiscoveredModelsResponse.ts new file mode 100644 index 000000000..5d6da3ace --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ListDiscoveredModelsResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { DiscoveredModel } from "./DiscoveredModel.js"; + +export const ListDiscoveredModelsResponse: core.serialization.ObjectSchema< + serializers.ListDiscoveredModelsResponse.Raw, + TrueForge.ListDiscoveredModelsResponse +> = core.serialization.object({ + data: core.serialization.list(DiscoveredModel), +}); + +export declare namespace ListDiscoveredModelsResponse { + export interface Raw { + data: DiscoveredModel.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index c1114ffdd..021aa1482 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -50,6 +50,7 @@ export * from "./CustomModelProvider.js"; export * from "./DaytonaSandboxProviderAuth.js"; export * from "./DeleteAgentResponse.js"; export * from "./DeleteScheduleResponse.js"; +export * from "./DiscoveredModel.js"; export * from "./DynamicSubAgentsConfig.js"; export * from "./ExtendedChunkDeltaToolCall.js"; export * from "./FileContent.js"; @@ -83,6 +84,7 @@ export * from "./ListAgentsResponse.js"; export * from "./ListAvailableMcpServersResponse.js"; export * from "./ListAvailableModelsResponse.js"; export * from "./ListAvailableSkillsResponse.js"; +export * from "./ListDiscoveredModelsResponse.js"; export * from "./ListMcpServersResponse.js"; export * from "./ListMcpServerToolsResponse.js"; export * from "./ListModelProvidersResponse.js"; diff --git a/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts b/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts index 6551d91b6..8180bd1fc 100644 --- a/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts +++ b/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts @@ -464,4 +464,87 @@ describe("ModelProvidersClient", () => { }); }).rejects.toThrow(TrueForgeTypes.FailedDependencyError); }); + + test("discovered_models (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { data: [{ context_length: 1, max_output_tokens: 1, model_id: "model_id" }] }; + + server + .mockEndpoint() + .get("/api/v1/settings/model-providers/name/discovered-models") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.settings.modelProviders.discoveredModels("name"); + expect(response).toEqual({ + data: [ + { + contextLength: 1, + maxOutputTokens: 1, + modelId: "model_id", + }, + ], + }); + }); + + test("discovered_models (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/v1/settings/model-providers/name/discovered-models") + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.modelProviders.discoveredModels("name"); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); + + test("discovered_models (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/v1/settings/model-providers/name/discovered-models") + .respondWith() + .statusCode(501) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.modelProviders.discoveredModels("name"); + }).rejects.toThrow(TrueForgeTypes.NotImplementedError); + }); + + test("discovered_models (4)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/v1/settings/model-providers/name/discovered-models") + .respondWith() + .statusCode(502) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.modelProviders.discoveredModels("name"); + }).rejects.toThrow(TrueForgeTypes.BadGatewayError); + }); }); diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index d73e6bb83..6517425b8 100644 --- a/packages/trueforge/src/apis/modelProviders.ts +++ b/packages/trueforge/src/apis/modelProviders.ts @@ -7,8 +7,14 @@ import { type ModelProviderRecord, } from '../db/modelProviderStore'; import type { WithTransaction } from '../db/transaction'; +import { + ModelDiscoveryError, + ModelDiscoveryUnsupportedError, + discoverProviderModels, +} from '../modelDiscovery/discoverProviderModels'; import { createModelProviderRoute, + listDiscoveredModelsRoute, listModelProvidersRoute, putModelProviderRoute, } from '../routes/modelProviderRoutes'; @@ -124,9 +130,34 @@ export function createModelProvidersRouter(deps: ModelProvidersRou } }; + const listDiscoveredModelsHandler: RouteHandler = async c => { + const { name } = c.req.valid('param'); + const requestContext = deps.resolveRequestContext(c); + // getProvider is model-scoped; discovery is about the provider row itself. + const records = await deps.resolveModelProviderStore(c).listProviders({ tenant_id: requestContext.tenant_id }); + const record = records.find(candidate => candidate.name === name); + if (record === undefined) { + return c.json({ error: { message: `No model provider configured under "${name}"` } }, 404); + } + try { + // The stored manifest carries the real key; nothing key-shaped comes from the request. + const { models } = await discoverProviderModels(record.manifest); + return c.json({ data: models }, 200); + } catch (error) { + if (error instanceof ModelDiscoveryUnsupportedError) { + return c.json({ error: { message: error.message } }, 501); + } + if (error instanceof ModelDiscoveryError) { + return c.json({ error: { message: error.message } }, 502); + } + throw error; + } + }; + const router = new OpenAPIHono(); router.openapi(listModelProvidersRoute, listHandler); router.openapi(createModelProviderRoute, createHandler); router.openapi(putModelProviderRoute, putHandler); + router.openapi(listDiscoveredModelsRoute, listDiscoveredModelsHandler); return router; } diff --git a/packages/trueforge/src/modelDiscovery/discoverProviderModels.ts b/packages/trueforge/src/modelDiscovery/discoverProviderModels.ts new file mode 100644 index 000000000..e7e1fb91e --- /dev/null +++ b/packages/trueforge/src/modelDiscovery/discoverProviderModels.ts @@ -0,0 +1,157 @@ +/** + * Live model discovery against a configured provider's own API. + * + * The shipped catalog (catalog/model-catalog.yaml) is a hand-maintained preset list and goes stale + * as providers ship models. Discovery asks the provider directly, so the model list — and the token + * limits that drive context compaction — come from the source of truth instead of a checked-in file. + * + * The API key never leaves the server: callers pass a resolved manifest, not a client-supplied key. + */ +import type { ModelProviderManifest } from '../schemas/modelProvider'; + +/** A model the provider reports, shaped for the UI to copy into a manifest write. */ +export interface DiscoveredModel { + model_id: string; + context_length?: number; + max_output_tokens?: number; +} + +export interface DiscoveryResult { + models: DiscoveredModel[]; +} + +/** Discovery reached the provider but it refused, or the response was not usable. */ +export class ModelDiscoveryError extends Error { + constructor(message: string) { + super(message); + this.name = 'ModelDiscoveryError'; + } +} + +/** The provider type has no discovery adapter; the caller falls back to catalog presets. */ +export class ModelDiscoveryUnsupportedError extends Error { + constructor(readonly providerType: string) { + super(`Model discovery is not supported for provider type "${providerType}"`); + this.name = 'ModelDiscoveryUnsupportedError'; + } +} + +const DISCOVERY_TIMEOUT_MS = 10_000; + +/** A positive integer, or undefined — provider payloads are untrusted and fields are optional. */ +function positiveInt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} + +async function fetchJson(url: string, headers: Record): Promise { + let response: Response; + try { + response = await fetch(url, { headers, signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new ModelDiscoveryError(`Could not reach the provider: ${reason}`); + } + if (!response.ok) { + // The body may carry the provider's own reason (bad key, disabled API); surface it, capped. + const detail = await response.text().catch(() => ''); + throw new ModelDiscoveryError( + `Provider returned ${String(response.status)}${detail ? `: ${detail.slice(0, 200)}` : ''}`.trim(), + ); + } + try { + return await response.json(); + } catch { + throw new ModelDiscoveryError('Provider returned a response that was not JSON.'); + } +} + +/** + * Gemini's native list endpoint. Unlike the OpenAI-compatible shape it reports token limits, so + * discovered Gemini models arrive with the properties that context compaction needs. + */ +async function discoverGoogleGemini(baseUrl: string, apiKey: string): Promise { + const url = `${baseUrl.replace(/\/+$/, '')}/models?key=${encodeURIComponent(apiKey)}&pageSize=1000`; + const payload = await fetchJson(url, {}); + const rawModels = (payload as { models?: unknown }).models; + if (!Array.isArray(rawModels)) { + throw new ModelDiscoveryError('Provider response did not contain a model list.'); + } + + const models: DiscoveredModel[] = []; + for (const entry of rawModels) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + const record = entry as Record; + // Only models usable for chat; the list also carries embedding and TTS-only models. + const methods = record['supportedGenerationMethods']; + if (!Array.isArray(methods) || !methods.includes('generateContent')) { + continue; + } + const name = record['name']; + if (typeof name !== 'string' || name === '') { + continue; + } + + const contextLength = positiveInt(record['inputTokenLimit']); + const maxOutputTokens = positiveInt(record['outputTokenLimit']); + models.push({ + // The list returns "models/gemini-x"; the id sent on a request is the bare suffix. + model_id: name.replace(/^models\//, ''), + ...(contextLength !== undefined ? { context_length: contextLength } : {}), + ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), + }); + } + return { models }; +} + +/** + * The OpenAI-compatible `GET /models`, which every remaining well-known provider speaks. It reports + * ids only — no token limits — so models discovered this way carry no properties. + */ +async function discoverOpenAiCompatible(baseUrl: string, apiKey: string): Promise { + const url = `${baseUrl.replace(/\/+$/, '')}/models`; + const payload = await fetchJson(url, { Authorization: `Bearer ${apiKey}` }); + const rawModels = (payload as { data?: unknown }).data; + if (!Array.isArray(rawModels)) { + throw new ModelDiscoveryError('Provider response did not contain a model list.'); + } + + const models: DiscoveredModel[] = []; + for (const entry of rawModels) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + const id = (entry as Record)['id']; + if (typeof id === 'string' && id !== '') { + models.push({ model_id: id }); + } + } + return { models }; +} + +/** + * Asks a configured provider which models it serves. + * + * @throws ModelDiscoveryUnsupportedError when the provider type has no adapter. + * @throws ModelDiscoveryError when the provider is unreachable or rejects the request. + */ +export async function discoverProviderModels(manifest: ModelProviderManifest): Promise { + const apiKey = manifest.auth?.api_key; + if (apiKey === undefined || apiKey === '') { + throw new ModelDiscoveryError('Provider has no stored API key to authenticate discovery.'); + } + const baseUrl = 'base_url' in manifest ? manifest.base_url : undefined; + if (typeof baseUrl !== 'string' || baseUrl === '') { + throw new ModelDiscoveryUnsupportedError(manifest.type); + } + + if (manifest.type === 'google-gemini') { + return discoverGoogleGemini(baseUrl, apiKey); + } + // `truefoundry` resolves its endpoint and token at runtime rather than from the manifest. + if (manifest.type === 'truefoundry') { + throw new ModelDiscoveryUnsupportedError(manifest.type); + } + return discoverOpenAiCompatible(baseUrl, apiKey); +} diff --git a/packages/trueforge/src/routes/modelProviderRoutes.ts b/packages/trueforge/src/routes/modelProviderRoutes.ts index c0b8058a4..e6312b36c 100644 --- a/packages/trueforge/src/routes/modelProviderRoutes.ts +++ b/packages/trueforge/src/routes/modelProviderRoutes.ts @@ -3,11 +3,12 @@ * Discovery catalog lives at GET /api/v1/catalogs/model-providers. * Handlers are registered in apis/modelProviders.ts. */ -import { createRoute } from '@hono/zod-openapi'; +import { createRoute, z } from '@hono/zod-openapi'; import { RequestErrorResponseSchema } from '../schemas/errors'; import { CreateModelProviderRequestSchema, GetModelProviderResponseSchema, + ListDiscoveredModelsResponseSchema, ListModelProvidersResponseSchema, UpdateModelProviderRequestSchema, } from '../schemas/modelProvider'; @@ -104,3 +105,39 @@ export const putModelProviderRoute = createRoute({ }, }, }); + +export const listDiscoveredModelsRoute = createRoute({ + method: 'get', + path: '/{name}/discovered-models', + tags: [OpenApiTag.MODELS], + summary: 'List the models a configured provider reports', + description: + 'Asks the provider itself which models it serves, using the stored API key. Returns token limits ' + + 'when the provider reports them (Gemini does; the OpenAI-compatible list does not). The shipped ' + + 'catalog is a preset list and may lag the provider, so this is the current source of truth.', + 'x-fern-sdk-group-name': ['settings', 'modelProviders'], + 'x-fern-sdk-method-name': 'discovered_models', + request: { + params: z.object({ + name: z.string().min(1).describe('Configured provider resource name, e.g. `google-gemini`.'), + }), + }, + responses: { + 200: { + content: { 'application/json': { schema: ListDiscoveredModelsResponseSchema } }, + description: 'Models the provider reports', + }, + 404: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'No provider is configured under this name.', + }, + 501: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'This provider type has no discovery adapter.', + }, + 502: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'The provider was unreachable or rejected the request.', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/modelProvider.ts b/packages/trueforge/src/schemas/modelProvider.ts index c6bed6176..98f95b93b 100644 --- a/packages/trueforge/src/schemas/modelProvider.ts +++ b/packages/trueforge/src/schemas/modelProvider.ts @@ -225,6 +225,32 @@ export const ListModelProvidersResponseSchema = z }) .openapi('ListModelProvidersResponse'); +/** A model the provider itself reports, for the UI to copy into a manifest write. */ +export const DiscoveredModelSchema = z + .object({ + model_id: z.string().min(1).describe('Upstream, provider-specific identifier sent to the provider API.'), + context_length: z + .number() + .int() + .positive() + .optional() + .describe('Maximum context window in tokens, when the provider reports one.'), + max_output_tokens: z + .number() + .int() + .positive() + .optional() + .describe('Maximum output tokens, when the provider reports one.'), + }) + .strict() + .openapi('DiscoveredModel'); + +export const ListDiscoveredModelsResponseSchema = z + .object({ + data: z.array(DiscoveredModelSchema), + }) + .openapi('ListDiscoveredModelsResponse'); + /** Provider identity on the models list read view. */ export const AvailableModelProviderSchema = z .object({ diff --git a/packages/trueforge/tests/unit/modelDiscovery/discoverProviderModels.test.ts b/packages/trueforge/tests/unit/modelDiscovery/discoverProviderModels.test.ts new file mode 100644 index 000000000..31ca0e2f6 --- /dev/null +++ b/packages/trueforge/tests/unit/modelDiscovery/discoverProviderModels.test.ts @@ -0,0 +1,193 @@ +/** + * Provider model discovery with fetch stubbed (same pattern as the MCP OAuth tests). + * Covers the two response shapes — Gemini's native list and the OpenAI-compatible one — + * plus the failure paths the route maps onto 501 and 502. + */ +import { + ModelDiscoveryError, + ModelDiscoveryUnsupportedError, + discoverProviderModels, +} from '../../../src/modelDiscovery/discoverProviderModels'; +import type { ModelProviderManifest } from '../../../src/schemas/modelProvider'; + +const realFetch = globalThis.fetch; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** + * Captures the URL and headers the module requested, so key placement is asserted, not assumed. + * `makeResponse` runs per call: a Response body reads only once, so every call needs a fresh one. + */ +function stubFetch(makeResponse: () => Response): { calls: { url: string; headers: HeadersInit }[] } { + const calls: { url: string; headers: HeadersInit }[] = []; + globalThis.fetch = (async (input, init) => { + calls.push({ url: String(input), headers: init?.headers ?? {} }); + return makeResponse(); + }) as typeof globalThis.fetch; + return { calls }; +} + +/** Resolves to whatever the call threw, so one rejection can be asserted from several angles. */ +async function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected the call to reject, but it resolved'); + }, + (error: unknown) => error, + ); +} + +const geminiManifest = { + type: 'google-gemini', + base_url: 'https://generativelanguage.googleapis.com/v1beta', + auth: { api_key: 'test-key' }, + models: [{ model_id: 'gemini-1.0', name: 'gemini-1-0', properties: {} }], +} as unknown as ModelProviderManifest; + +const openAiManifest = { + type: 'openai', + base_url: 'https://api.openai.com/v1', + auth: { api_key: 'test-key' }, + models: [{ model_id: 'gpt-x', name: 'gpt-x', properties: {} }], +} as unknown as ModelProviderManifest; + +describe('discoverProviderModels', () => { + afterEach(() => { + globalThis.fetch = realFetch; + }); + + describe('google-gemini', () => { + it('maps token limits onto model properties', async () => { + stubFetch(() => + json({ + models: [ + { + name: 'models/gemini-3.8-flash', + supportedGenerationMethods: ['generateContent'], + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + }, + ], + }), + ); + + const { models } = await discoverProviderModels(geminiManifest); + + expect(models).toEqual([{ model_id: 'gemini-3.8-flash', context_length: 1048576, max_output_tokens: 65536 }]); + }); + + it('skips models that cannot serve generateContent', async () => { + stubFetch(() => + json({ + models: [ + { name: 'models/text-embedding-004', supportedGenerationMethods: ['embedContent'] }, + { name: 'models/gemini-3.8-flash', supportedGenerationMethods: ['generateContent'] }, + ], + }), + ); + + const { models } = await discoverProviderModels(geminiManifest); + + expect(models.map(model => model.model_id)).toEqual(['gemini-3.8-flash']); + }); + + it('omits limits the provider does not report rather than inventing them', async () => { + stubFetch(() => + json({ + models: [ + { + name: 'models/gemini-3.8-flash', + supportedGenerationMethods: ['generateContent'], + inputTokenLimit: 0, + outputTokenLimit: 'not-a-number', + }, + ], + }), + ); + + const { models } = await discoverProviderModels(geminiManifest); + + expect(models).toEqual([{ model_id: 'gemini-3.8-flash' }]); + }); + + it('sends the key as a query parameter, not a bearer header', async () => { + const stub = stubFetch(() => json({ models: [] })); + + await discoverProviderModels(geminiManifest); + + expect(stub.calls[0]?.url).toContain('key=test-key'); + expect(stub.calls[0]?.headers).toEqual({}); + }); + }); + + describe('openai-compatible', () => { + it('returns ids with no properties, since the shape carries no limits', async () => { + stubFetch(() => json({ data: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }] })); + + const { models } = await discoverProviderModels(openAiManifest); + + expect(models).toEqual([{ model_id: 'gpt-5' }, { model_id: 'gpt-5-mini' }]); + }); + + it('sends the key as a bearer header, keeping it out of the URL', async () => { + const stub = stubFetch(() => json({ data: [] })); + + await discoverProviderModels(openAiManifest); + + expect(stub.calls[0]?.headers).toEqual({ Authorization: 'Bearer test-key' }); + expect(stub.calls[0]?.url).not.toContain('test-key'); + }); + }); + + describe('failures', () => { + it('surfaces the provider status and body on a rejected request', async () => { + stubFetch(() => new Response('bad key', { status: 403 })); + + const error = await rejection(discoverProviderModels(geminiManifest)); + + expect(error).toBeInstanceOf(ModelDiscoveryError); + expect((error as Error).message).toMatch(/403.*bad key/s); + }); + + it('rejects a payload whose model list is missing', async () => { + stubFetch(() => json({ notModels: [] })); + + const error = await rejection(discoverProviderModels(geminiManifest)); + + expect((error as Error).message).toMatch(/did not contain a model list/); + }); + + it('reports an unreachable provider rather than throwing raw', async () => { + stubFetch(() => { + throw new Error('ECONNREFUSED'); + }); + + const error = await rejection(discoverProviderModels(geminiManifest)); + + expect((error as Error).message).toMatch(/Could not reach the provider/); + }); + + it('refuses to call out when no API key is stored', async () => { + const manifest = { ...geminiManifest, auth: { api_key: '' } } as unknown as ModelProviderManifest; + + const error = await rejection(discoverProviderModels(manifest)); + + expect((error as Error).message).toMatch(/no stored API key/); + }); + + it('marks truefoundry unsupported, since its endpoint is resolved at runtime', async () => { + const manifest = { + type: 'truefoundry', + base_url: 'https://gateway.example.com', + auth: { api_key: 'test-key' }, + models: [], + } as unknown as ModelProviderManifest; + + const error = await rejection(discoverProviderModels(manifest)); + + expect(error).toBeInstanceOf(ModelDiscoveryUnsupportedError); + }); + }); +});