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);
+ });
+ });
+});