From 62d782101fd85d67ea7ac85a2f434efc47b46667 Mon Sep 17 00:00:00 2001 From: Jayesh Savaliya Date: Wed, 9 Sep 2026 17:35:50 +0530 Subject: [PATCH] feat(api): discover a provider's models from the provider itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped catalog is a hand-maintained preset list, so a well-known provider offers only the models checked into model-catalog.yaml — google-gemini ships 2 against openai's 5 and anthropic's 6 — and it goes stale whenever a provider releases a model. The catalog is also the only source of model properties. A model configured without them has no context_length, and resolveCompactionThresholdTokens then falls back to DEFAULT_CONTEXT_COMPACTION_THRESHOLD_TOKENS (50k) instead of 80% of the real window. For a 1M-context Gemini model that compacts roughly 17x earlier than it should, silently. Add GET /settings/model-providers/{name}/discovered-models, which asks the provider which models it serves using the stored key. Gemini's native list reports inputTokenLimit and outputTokenLimit, so discovered Gemini models carry the properties compaction needs; the remaining providers use the OpenAI-compatible list, which reports ids only, and those models are returned without invented limits. The key is read from the stored manifest and never crosses the wire. Gemini takes it as a query parameter and the compatible shape as a bearer header; both are covered by tests. Refs #648 Co-Authored-By: Claude Opus 5 --- .changeset/discover-provider-models.md | 5 + .github/fern/openapi/openapi.json | 107 ++++++++++ docs/openapi.json | 107 ++++++++++ packages/trueforge-sdk/reference.md | 63 ++++++ .../src/api/errors/NotImplementedError.ts | 24 +++ .../trueforge-sdk/src/api/errors/index.ts | 1 + .../resources/modelProviders/client/Client.ts | 112 ++++++++++ .../src/api/types/DiscoveredModel.ts | 10 + .../api/types/ListDiscoveredModelsResponse.ts | 7 + packages/trueforge-sdk/src/api/types/index.ts | 2 + .../serialization/types/DiscoveredModel.ts | 22 ++ .../types/ListDiscoveredModelsResponse.ts | 19 ++ .../src/serialization/types/index.ts | 2 + .../wire/settings/modelProviders.test.ts | 83 ++++++++ packages/trueforge/src/apis/modelProviders.ts | 31 +++ .../modelDiscovery/discoverProviderModels.ts | 157 ++++++++++++++ .../src/routes/modelProviderRoutes.ts | 39 +++- .../trueforge/src/schemas/modelProvider.ts | 26 +++ .../discoverProviderModels.test.ts | 193 ++++++++++++++++++ 19 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 .changeset/discover-provider-models.md create mode 100644 packages/trueforge-sdk/src/api/errors/NotImplementedError.ts create mode 100644 packages/trueforge-sdk/src/api/types/DiscoveredModel.ts create mode 100644 packages/trueforge-sdk/src/api/types/ListDiscoveredModelsResponse.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/DiscoveredModel.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/ListDiscoveredModelsResponse.ts create mode 100644 packages/trueforge/src/modelDiscovery/discoverProviderModels.ts create mode 100644 packages/trueforge/tests/unit/modelDiscovery/discoverProviderModels.test.ts 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); + }); + }); +});