Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/alien-ai-gateway/src/router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,7 @@ async fn list_models(
"object": "model",
"provider": model.provider(),
"displayName": model.display_name(),
"capabilities": model.capabilities(),
})
})
.collect(),
Expand All @@ -1174,6 +1175,7 @@ async fn list_models(
"object": "model",
"provider": "anthropic",
"displayName": model.display_name(),
"capabilities": ai_catalog::unverified_model_capabilities(),
})
})
.collect(),
Expand All @@ -1195,6 +1197,7 @@ async fn list_models(
"object": "model",
"provider": "openai",
"displayName": model,
"capabilities": ai_catalog::unverified_model_capabilities(),
})
})
.collect()
Expand All @@ -1217,6 +1220,7 @@ async fn list_models(
"object": "model",
"provider": "databricks",
"displayName": model,
"capabilities": ai_catalog::unverified_model_capabilities(),
})
})
.collect()
Expand Down Expand Up @@ -3317,6 +3321,24 @@ mod tests {
.expect("byo/gpt-oss-20b entry");
assert_eq!(gpt["provider"], "openai");
assert_eq!(gpt["displayName"], "GPT-OSS 20B");
let capabilities = gpt["capabilities"].as_array().expect("capability matrix");
assert_eq!(capabilities.len(), ClientApi::ALL.len());
let responses = capabilities
.iter()
.find(|entry| entry["api"] == "open-ai-responses")
.expect("Responses capabilities");
assert_eq!(responses["functionTools"], "supported");
assert_eq!(responses["serverManagedContinuation"], "supported");
assert_eq!(responses["statelessReplay"], "unsupported");
assert_eq!(responses["imageInput"], "unverified");
assert_eq!(responses["reasoningControls"], "unverified");
assert_eq!(responses["qualifiedOn"], "2026-09-22");
let chat = capabilities
.iter()
.find(|entry| entry["api"] == "open-ai-chat-completions")
.expect("Chat Completions capabilities");
assert_eq!(chat["functionTools"], "unverified");
assert!(chat.get("qualifiedOn").is_none());
assert_eq!(
openai.hits_async().await,
0,
Expand Down
72 changes: 72 additions & 0 deletions crates/alien-core/src/ai_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,57 @@ pub enum ClientApi {
AnthropicMessages,
}

/// Whether a model feature has been qualified through one public Gateway API.
///
/// `Unverified` is deliberately different from `Unsupported`: Alien does not infer support from
/// a model family or from the provider-native protocol used behind the Gateway.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ModelCapabilitySupport {
Supported,
Unsupported,
Unverified,
}

/// Qualified model behavior for one public Gateway API.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelApiCapabilities {
pub api: ClientApi,
pub function_tools: ModelCapabilitySupport,
pub image_input: ModelCapabilitySupport,
pub reasoning_controls: ModelCapabilitySupport,
pub server_managed_continuation: ModelCapabilitySupport,
pub stateless_replay: ModelCapabilitySupport,
/// Date of the live qualification evidence, or `None` when every fact is unverified.
#[serde(skip_serializing_if = "Option::is_none")]
pub qualified_on: Option<String>,
}

impl ModelApiCapabilities {
fn unverified(api: ClientApi) -> Self {
Self {
api,
function_tools: ModelCapabilitySupport::Unverified,
image_input: ModelCapabilitySupport::Unverified,
reasoning_controls: ModelCapabilitySupport::Unverified,
server_managed_continuation: ModelCapabilitySupport::Unverified,
stateless_replay: ModelCapabilitySupport::Unverified,
qualified_on: None,
}
}
}

/// An explicit all-unverified matrix for provider models without live qualification evidence.
pub fn unverified_model_capabilities() -> Vec<ModelApiCapabilities> {
ClientApi::ALL
.into_iter()
.map(ModelApiCapabilities::unverified)
.collect()
}

impl ClientApi {
/// Public request protocols accepted for every text-generation model. The
/// gateway translates to the model's provider-native protocol when needed.
Expand Down Expand Up @@ -141,6 +192,27 @@ impl DirectAnthropicModel {
}

impl CatalogModel {
/// Qualified behavior per public API. Every API is present so callers cannot mistake an
/// omitted record for lack of support.
pub fn capabilities(&self) -> Vec<ModelApiCapabilities> {
let mut capabilities = unverified_model_capabilities();

// Live AWS qualification on 2026-09-22. These facts apply only to this exact
// provider/model/API tuple; the Chat Completions and Messages adapters remain unverified.
if self.cloud == Platform::Aws && self.public_id == "gpt-oss-20b" {
let responses = capabilities
.iter_mut()
.find(|entry| entry.api == ClientApi::OpenAiResponses)
.expect("the public API matrix always contains Responses");
responses.function_tools = ModelCapabilitySupport::Supported;
responses.server_managed_continuation = ModelCapabilitySupport::Supported;
responses.stateless_replay = ModelCapabilitySupport::Unsupported;
responses.qualified_on = Some("2026-09-22".to_string());
}

capabilities
}

/// The model's publisher, for grouping in a picker. Derived from the public id,
/// so the same public id reports the same provider on every cloud.
pub fn provider(&self) -> &'static str {
Expand Down
3 changes: 3 additions & 0 deletions crates/alien-core/src/bin/schema_exporter.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use alien_core::ai_catalog::{ModelApiCapabilities, ModelCapabilitySupport};
use alien_core::sync::{
DynamicContainerHealthCheck, DynamicContainerReport, DynamicContainerStatus,
ObservedApplicationImage, ObservedApplicationReport, ObservedApplicationSource,
Expand Down Expand Up @@ -131,6 +132,8 @@ use utoipa::OpenApi;
PostgresOutputs,
Ai,
AiOutputs,
ModelApiCapabilities,
ModelCapabilitySupport,
Queue,
QueueOutputs,
Sandbox,
Expand Down
53 changes: 45 additions & 8 deletions packages/ai-gateway/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ const EXTERNAL = JSON.stringify({ service: "external-ai", provider: "openai", ap

const GATEWAY_URL = "http://127.0.0.1:41999"

const unverifiedCapabilities = [
"open-ai-chat-completions",
"open-ai-responses",
"anthropic-messages",
].map(api => ({
api,
functionTools: "unverified",
imageInput: "unverified",
reasoningControls: "unverified",
serverManagedContinuation: "unverified",
statelessReplay: "unverified",
}))

const gatewayModel = {
id: "gpt-oss-20b",
provider: "openai",
displayName: "GPT-OSS 20B",
capabilities: unverifiedCapabilities,
}

const stubGateway: Gateway = {
startAiGateway: () => Promise.resolve({ url: GATEWAY_URL } as unknown as RawAiGatewayHandle),
}
Expand Down Expand Up @@ -256,24 +276,43 @@ describe("Ai.getAvailableModels", () => {
expect(ids).toContain("claude-sonnet-5")
expect(ids).toContain("claude-haiku-4-5")
expect(ids.some(id => id.startsWith("claude-3-5"))).toBe(false)
// Each entry carries the provider/displayName shape a model picker consumes.
expect(models[0]).toMatchObject({ provider: "anthropic", displayName: models[0]!.id })
// Each entry carries the same explicit capability shape as a Gateway model.
expect(models[0]).toMatchObject({
provider: "anthropic",
displayName: models[0]!.id,
capabilities: unverifiedCapabilities,
})
expect(fetchMock).not.toHaveBeenCalled()
})

it("fetches the gateway's curated catalog for an ambient binding", async () => {
vi.stubEnv("ALIEN_LLM_BINDING", JSON.stringify({ service: "bedrock", region: "us-east-2" }))
const fetchMock = stubFetch({
data: [{ id: "gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" }],
data: [gatewayModel],
})
const models = await ai("llm").getAvailableModels()
expect(callUrl(fetchMock)).toBe(`${GATEWAY_URL}/llm/v1/models`)
expect(models).toEqual([{ id: "gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" }])
expect(models).toEqual([gatewayModel])
})

it("rejects a model list whose capability matrix is incomplete", async () => {
vi.stubEnv("ALIEN_LLM_BINDING", JSON.stringify({ service: "bedrock", region: "us-east-2" }))
stubFetch({ data: [{ ...gatewayModel, capabilities: unverifiedCapabilities.slice(1) }] })
await expect(ai("llm").getAvailableModels()).rejects.toThrow(
"models response entry has an invalid capabilities matrix",
)
})

it("reports every API as unverified when an older gateway omits the matrix", async () => {
vi.stubEnv("ALIEN_LLM_BINDING", JSON.stringify({ service: "bedrock", region: "us-east-2" }))
const { capabilities: _omitted, ...legacyModel } = gatewayModel
stubFetch({ data: [legacyModel] })
expect(await ai("llm").getAvailableModels()).toEqual([gatewayModel])
})

it("retries a transient gateway-start failure on a retained instance", async () => {
vi.stubEnv("ALIEN_LLM_BINDING", JSON.stringify({ service: "bedrock", region: "us-east-2" }))
stubFetch({ data: [{ id: "gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" }] })
stubFetch({ data: [gatewayModel] })
const start = vi
.fn()
.mockRejectedValueOnce(new Error("ambient credential unavailable"))
Expand All @@ -282,9 +321,7 @@ describe("Ai.getAvailableModels", () => {

await expect(llm.getAvailableModels()).rejects.toThrow()
// A cached rejection would leave this instance permanently broken; it must retry.
expect(await llm.getAvailableModels()).toEqual([
{ id: "gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" },
])
expect(await llm.getAvailableModels()).toEqual([gatewayModel])
expect(start).toHaveBeenCalledTimes(2)
})
})
Expand Down
75 changes: 72 additions & 3 deletions packages/ai-gateway/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export interface ResponseCreateParams {
[key: string]: unknown
}

/** Whether a behavior was qualified through one public API. `unverified` is not `unsupported`. */
export type ModelCapabilitySupport = "supported" | "unsupported" | "unverified"

export interface ModelApiCapabilities {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
api: "open-ai-chat-completions" | "open-ai-responses" | "anthropic-messages"
functionTools: ModelCapabilitySupport
imageInput: ModelCapabilitySupport
reasoningControls: ModelCapabilitySupport
serverManagedContinuation: ModelCapabilitySupport
statelessReplay: ModelCapabilitySupport
qualifiedOn?: string
}

/** One model the gateway exposes for this binding's cloud. */
export interface AiModel {
/** The id passed to `chat.completions.create` / `responses.create`. */
Expand All @@ -54,6 +67,8 @@ export interface AiModel {
provider: string
/** A human label for a model picker, e.g. "Claude Opus 4.8". */
displayName: string
/** Qualified behavior for each public Gateway API. */
capabilities: ModelApiCapabilities[]
}

/**
Expand Down Expand Up @@ -113,6 +128,23 @@ function defaultModels(provider: string): string[] {
: ["gpt-4o-mini", "gpt-4o"]
}

const PUBLIC_AI_APIS: ModelApiCapabilities["api"][] = [
"open-ai-chat-completions",
"open-ai-responses",
"anthropic-messages",
]

function unverifiedModelCapabilities(): ModelApiCapabilities[] {
return PUBLIC_AI_APIS.map(api => ({
api,
functionTools: "unverified",
imageInput: "unverified",
reasoningControls: "unverified",
serverManagedContinuation: "unverified",
statelessReplay: "unverified",
}))
}

/** Resolution shared by `ai()` and `getAiConnection()`. `baseUrl` is the root (no `/v1`);
* `apiKey`/`staticModels` are set only for a BYO-key (External) provider. */
export interface ResolvedAiBinding {
Expand Down Expand Up @@ -145,6 +177,7 @@ export async function resolveAiBinding(gateway: Gateway, name: string): Promise<
id,
provider: binding.provider,
displayName: id,
capabilities: unverifiedModelCapabilities(),
})),
}
}
Expand Down Expand Up @@ -334,7 +367,7 @@ export class Ai {
// The gateway subprocess is a separate trust boundary (it can be a stale or
// mismatched binary), so verify each element actually carries the AiModel
// shape instead of letting a type assertion hide missing fields.
for (const model of body.data) {
return body.data.map(model => {
if (
typeof model?.id !== "string" ||
typeof model?.provider !== "string" ||
Expand All @@ -346,8 +379,20 @@ export class Ai {
`models response entry is missing id/provider/displayName: ${JSON.stringify(model)}`,
)
}
}
return body.data
// A gateway binary older than the capability matrix omits the field. That means no
// qualification evidence, which is exactly "unverified" for every API.
if (model.capabilities === undefined) {
return { ...model, capabilities: unverifiedModelCapabilities() }
}
if (!isModelCapabilities(model.capabilities)) {
throw createUpstreamError(
url,
response.status,
`models response entry has an invalid capabilities matrix: ${JSON.stringify(model)}`,
)
}
return model
})
}

private _chatCompletionsCreate(params: ChatCompletionCreateParams): Promise<unknown> {
Expand Down Expand Up @@ -421,6 +466,30 @@ export class Ai {
}
}

function isModelCapabilities(value: unknown): value is ModelApiCapabilities[] {
if (!Array.isArray(value) || value.length !== PUBLIC_AI_APIS.length) return false
const support = new Set<ModelCapabilitySupport>(["supported", "unsupported", "unverified"])
const seen = new Set<string>()
for (const entry of value) {
if (!entry || typeof entry !== "object") return false
const capability = entry as Partial<ModelApiCapabilities>
if (!PUBLIC_AI_APIS.includes(capability.api as ModelApiCapabilities["api"])) return false
if (seen.has(capability.api as string)) return false
seen.add(capability.api as string)
if (
!support.has(capability.functionTools as ModelCapabilitySupport) ||
!support.has(capability.imageInput as ModelCapabilitySupport) ||
!support.has(capability.reasoningControls as ModelCapabilitySupport) ||
!support.has(capability.serverManagedContinuation as ModelCapabilitySupport) ||
!support.has(capability.statelessReplay as ModelCapabilitySupport) ||
(capability.qualifiedOn !== undefined && typeof capability.qualifiedOn !== "string")
) {
return false
}
}
return seen.size === PUBLIC_AI_APIS.length
}

// ─────────────────────────────────────────────────────────────────────────────
// Entry wiring
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export type {
AiConnection,
AiModel,
ChatCompletionCreateParams,
ModelApiCapabilities,
ModelCapabilitySupport,
ResponseCreateParams,
} from "./client.js"
export { Ai } from "./client.js"
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-gateway/src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type {
AiConnection,
AiModel,
ChatCompletionCreateParams,
ModelApiCapabilities,
ModelCapabilitySupport,
ResponseCreateParams,
} from "./client.js"
export { Ai } from "./client.js"
Expand Down
Loading
Loading