feat: introduced aiand provider registry entry and model catalog

- Implement the ai& provider registry entry with API-key authentication and login support.
- Add model descriptors, static model seeding, and openai-compatible model discovery for the ai& provider.
- Update the model catalog with ai& provider models, pricing, and updated provider model names.
- Add unit tests for the ai& provider environment resolution, metadata, and dynamic model mapping.
This commit is contained in:
can1357
2026-08-01 06:30:42 +02:00
parent ad78b6a84e
commit 71c755d5f6
11 changed files with 1002 additions and 127 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added the ai& (`aiand`) provider registry entry with API-key paste login validated against `https://api.aiand.com/v1/models`.
## [17.2.2] - 2026-07-31
### Added
+22
View File
@@ -0,0 +1,22 @@
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const loginAiand = createApiKeyLogin({
providerLabel: "ai&",
authUrl: "https://console.aiand.com/api-keys",
instructions: "Copy your API key from the ai& console",
promptMessage: "Paste your ai& API key",
placeholder: "sk-...",
validation: {
kind: "models-endpoint",
provider: "ai&",
modelsUrl: "https://api.aiand.com/v1/models",
},
});
export const aiandProvider = {
id: "aiand",
name: "ai&",
login: (cb: OAuthLoginCallbacks) => loginAiand(cb),
} as const satisfies ProviderDefinition;
+2
View File
@@ -1,4 +1,5 @@
import type { KnownProvider } from "@oh-my-pi/pi-catalog";
import { aiandProvider } from "./aiand";
import { aimlApiProvider } from "./aimlapi";
import { alibabaCodingPlanProvider } from "./alibaba-coding-plan";
import { alibabaTokenPlanProvider } from "./alibaba-token-plan";
@@ -101,6 +102,7 @@ const ALL = [
gitLabDuoWorkflowProvider,
alibabaCodingPlanProvider,
alibabaTokenPlanProvider,
aiandProvider,
aimlApiProvider,
zhipuCodingPlanProvider,
umansProvider,
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added support for the ai& provider (`aiand`), an OpenAI-compatible inference API with dynamic model discovery (context windows, capabilities, reasoning efforts, and USD pricing from `/v1/models`) and API-key authentication via the `AIAND_API_KEY` environment variable.
## [17.2.2] - 2026-07-31
### Added
@@ -29,6 +29,7 @@ import {
} from "../src/provider-models/descriptor-types";
import { PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
import {
AIAND_STATIC_MODELS,
ALIBABA_TOKEN_PLAN_STATIC_MODELS,
ANTHROPIC_CURATED_FALLBACK_MODELS,
buildFireworksFastSeed,
@@ -560,6 +561,12 @@ async function generateModels() {
if (!authoritativeCatalogProviders.has("sakana")) {
allModels.push(...SAKANA_FUGU_STATIC_MODELS);
}
// Seed ai&'s documented catalog so the provider is usable when generation
// has no AIAND_API_KEY. A live org-scoped `/v1/models` snapshot is
// authoritative and replaces the seed.
if (!authoritativeCatalogProviders.has("aiand")) {
allModels.push(...AIAND_STATIC_MODELS);
}
// Seed the GMI Cloud default model so a fresh install (and a regen without a
// `GMI_API_KEY`) still resolves the descriptor's `defaultModel` synchronously
// at boot. If live `/v1/models` discovery succeeds, it is authoritative.
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import type { ModelManagerConfig, ProviderCatalogEntry, ProviderDescriptor } fro
import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google";
import { ollamaCloudModelManagerOptions } from "./ollama";
import {
aiandModelManagerOptions,
aimlApiModelManagerOptions,
alibabaCodingPlanModelManagerOptions,
alibabaTokenPlanModelManagerOptions,
@@ -65,6 +66,14 @@ import {
} from "./special";
export const CATALOG_PROVIDERS = [
{
id: "aiand",
defaultModel: "moonshotai/kimi-k2.7-code",
envVars: ["AIAND_API_KEY"],
createModelManagerOptions: (config: ModelManagerConfig) => aiandModelManagerOptions(config),
dynamicModelsAuthoritative: true,
catalogDiscovery: { label: "ai&" },
},
{
id: "aimlapi",
defaultModel: "gpt-5.5-2026-04-23",
@@ -3796,6 +3796,175 @@ export function sakanaModelManagerOptions(config?: SakanaModelManagerConfig): Mo
};
}
// ---------------------------------------------------------------------------
// 16.6 ai& (aiand.com)
// ---------------------------------------------------------------------------
const AIAND_DEFAULT_BASE_URL = "https://api.aiand.com/v1";
/** `reasoning_efforts` wire values ai& reports, mapped onto pi effort levels. */
const AIAND_EFFORT_BY_WIRE_VALUE: Record<string, Effort> = {
minimal: Effort.Minimal,
low: Effort.Low,
medium: Effort.Medium,
high: Effort.High,
xhigh: Effort.XHigh,
max: Effort.Max,
};
function normalizeAiandBaseUrl(baseUrl: string | undefined): string {
const value = baseUrl?.trim() || AIAND_DEFAULT_BASE_URL;
const normalized = value.replace(/\/+$/, "");
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
}
function createAiandStaticModel(
id: string,
name: string,
cost: { input: number; output: number },
contextWindow: number,
input: ModelSpec<"openai-completions">["input"],
): ModelSpec<"openai-completions"> {
return {
id,
name,
api: "openai-completions",
provider: "aiand",
baseUrl: AIAND_DEFAULT_BASE_URL,
reasoning: true,
input: [...input],
cost: { input: cost.input, output: cost.output, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: null,
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High], defaultLevel: Effort.Medium },
};
}
/**
* Documented ai& catalog (docs.aiand.com/models/catalog, 2026-08) bundled so
* the provider is usable when generation and first boot have no live key.
* The org-scoped `/v1/models` response is authoritative once discovery runs.
*/
export const AIAND_STATIC_MODELS: readonly ModelSpec<"openai-completions">[] = [
createAiandStaticModel("qwen/qwen3.6-27b", "Qwen3.6 27B", { input: 0, output: 0 }, 262_144, ["text"]),
createAiandStaticModel(
"deepseek-ai/deepseek-v4-flash",
"DeepSeek V4 Flash",
{ input: 0.15, output: 0.25 },
1_000_000,
["text"],
),
createAiandStaticModel("google/gemma-4-31b-it", "Gemma 4 31B IT", { input: 0.2, output: 0.5 }, 262_144, [
"text",
"image",
]),
createAiandStaticModel("openai/gpt-oss-120b", "GPT OSS 120B", { input: 0.15, output: 0.6 }, 131_072, ["text"]),
createAiandStaticModel("deepseek-ai/deepseek-v4-pro", "DeepSeek V4 Pro", { input: 1, output: 2.5 }, 1_000_000, [
"text",
]),
createAiandStaticModel("moonshotai/kimi-k2.7-code", "Kimi K2.7 Code", { input: 0.75, output: 3.5 }, 262_144, [
"text",
"image",
]),
createAiandStaticModel("moonshotai/kimi-k2.6", "Kimi K2.6", { input: 0.85, output: 3.5 }, 262_144, [
"text",
"image",
]),
createAiandStaticModel("zai-org/glm-5.2", "GLM 5.2", { input: 1, output: 4 }, 1_000_000, ["text"]),
createAiandStaticModel("zai-org/glm-5.1", "GLM 5.1", { input: 1.4, output: 4.4 }, 202_752, ["text"]),
];
const AIAND_STATIC_MODEL_IDS = AIAND_STATIC_MODELS.map(model => model.id);
function mapAiandThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
const efforts = Array.isArray(entry.reasoning_efforts)
? entry.reasoning_efforts.flatMap(value =>
typeof value === "string" && AIAND_EFFORT_BY_WIRE_VALUE[value] ? [AIAND_EFFORT_BY_WIRE_VALUE[value]] : [],
)
: [];
if (efforts.length === 0) {
return undefined;
}
const defaultLevel =
typeof entry.reasoning_effort_default === "string"
? AIAND_EFFORT_BY_WIRE_VALUE[entry.reasoning_effort_default]
: undefined;
return {
mode: "effort",
efforts,
...(defaultLevel && efforts.includes(defaultLevel) && { defaultLevel }),
};
}
/**
* ai& reports prices as decimal strings per 1M tokens in the org's billing
* currency (`usd` or `jpy`). Costs are only mapped for USD orgs — JPY figures
* would corrupt the USD-denominated cost model, so they fall back to zero.
*/
function mapAiandCost(entry: OpenAICompatibleModelRecord): ModelSpec<"openai-completions">["cost"] {
if (typeof entry.currency === "string" && entry.currency !== "usd") {
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
}
return {
input: toPositiveNumber(entry.input_per_1m, 0),
output: toPositiveNumber(entry.output_per_1m, 0),
cacheRead: 0,
cacheWrite: 0,
};
}
function mapAiandModel(
entry: OpenAICompatibleModelRecord,
defaults: ModelSpec<"openai-completions">,
): ModelSpec<"openai-completions"> {
const capabilities: unknown[] = Array.isArray(entry.capabilities) ? entry.capabilities : [];
const reasoning = capabilities.includes("reasoning");
const thinking = reasoning ? mapAiandThinking(entry) : undefined;
const description =
typeof entry.description === "string" && entry.description.trim() ? entry.description : undefined;
return {
...defaults,
name: description ?? toModelName(entry.name, defaults.name),
reasoning,
input: capabilities.includes("vision") ? ["text", "image"] : ["text"],
cost: mapAiandCost(entry),
contextWindow: toPositiveNumber(entry.context_window, null),
...(thinking && { thinking }),
};
}
export interface AiandModelManagerConfig {
apiKey?: string;
baseUrl?: string;
fetch?: FetchImpl;
}
/**
* ai& (aiand.com) model manager: OpenAI-compatible chat completions with an
* org-scoped `/v1/models` catalog carrying context, capability, effort, and
* pricing metadata, so discovery is authoritative over the bundled seed.
*/
export function aiandModelManagerOptions(config?: AiandModelManagerConfig): ModelManagerOptions<"openai-completions"> {
const apiKey = config?.apiKey;
const baseUrl = normalizeAiandBaseUrl(config?.baseUrl ?? Bun.env.AIAND_BASE_URL);
return {
providerId: "aiand",
dynamicModelsAuthoritative: true,
dropCachedModelIdsOnStaticMismatch: AIAND_STATIC_MODEL_IDS,
...(apiKey && {
fetchDynamicModels: () =>
fetchOpenAICompatibleModels({
api: "openai-completions",
provider: "aiand",
baseUrl,
apiKey,
mapModel: (entry, defaults) => mapAiandModel(entry, defaults),
fetch: config?.fetch,
}),
}),
};
}
// ---------------------------------------------------------------------------
// 17. Qwen Portal
// ---------------------------------------------------------------------------
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, test, vi } from "bun:test";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth";
import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream";
import { Effort } from "@oh-my-pi/pi-catalog/effort";
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors";
import { aiandModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
import type { FetchImpl } from "@oh-my-pi/pi-catalog/types";
const ORIGINAL_ENV = {
AIAND_API_KEY: Bun.env.AIAND_API_KEY,
AIAND_BASE_URL: Bun.env.AIAND_BASE_URL,
} as const;
function restoreEnvVar(name: keyof typeof ORIGINAL_ENV): void {
const value = ORIGINAL_ENV[name];
if (value === undefined) {
delete Bun.env[name];
return;
}
Bun.env[name] = value;
}
afterEach(() => {
restoreEnvVar("AIAND_API_KEY");
restoreEnvVar("AIAND_BASE_URL");
vi.restoreAllMocks();
});
/** One entry in ai&'s documented `/v1/models` OpenAI-surface response shape. */
function aiandModelsResponse(entries: Record<string, unknown>[]): Response {
return new Response(JSON.stringify({ object: "list", data: entries }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("ai& provider support", () => {
test("resolves the AIAND_API_KEY environment fallback", () => {
Bun.env.AIAND_API_KEY = "aiand-test-key";
expect(getEnvApiKey("aiand")).toBe("aiand-test-key");
});
test("registers descriptor, default model, bundled seed, and login provider", () => {
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "aiand");
expect(descriptor).toBeDefined();
expect(descriptor?.defaultModel).toBe("moonshotai/kimi-k2.7-code");
expect(descriptor?.dynamicModelsAuthoritative).toBe(true);
expect(DEFAULT_MODEL_PER_PROVIDER.aiand).toBe("moonshotai/kimi-k2.7-code");
const bundled = getBundledModels("aiand");
const defaultModel = bundled.find(model => model.id === "moonshotai/kimi-k2.7-code");
expect(defaultModel).toBeDefined();
for (const model of bundled) {
expect(model.api).toBe("openai-completions");
expect(model.baseUrl).toBe("https://api.aiand.com/v1");
}
const provider = getOAuthProviders().find(item => item.id === "aiand");
expect(provider?.name).toBe("ai&");
});
test("maps ai& /v1/models metadata: context, capabilities, efforts, and USD pricing", async () => {
delete Bun.env.AIAND_BASE_URL;
const fetchMock: FetchImpl = vi.fn(async () =>
aiandModelsResponse([
{
id: "openai/gpt-oss-120b",
name: "openai/gpt-oss-120b",
description: "OpenAI GPT OSS 120B",
context_window: 131072,
capabilities: ["reasoning", "tool_calling"],
reasoning_efforts: ["low", "medium", "high"],
reasoning_effort_default: "medium",
currency: "usd",
input_per_1m: "0.150000",
output_per_1m: "0.600000",
},
{
id: "google/gemma-4-31b-it",
name: "google/gemma-4-31b-it",
context_window: 262144,
capabilities: ["tool_calling", "vision", "video", "document"],
reasoning_efforts: null,
currency: "usd",
input_per_1m: "0.200000",
output_per_1m: "0.500000",
},
]),
) as unknown as FetchImpl;
const options = aiandModelManagerOptions({ apiKey: "aiand-key", fetch: fetchMock });
expect(options.dynamicModelsAuthoritative).toBe(true);
const models = await options.fetchDynamicModels?.();
expect(fetchMock).toHaveBeenCalledWith(
"https://api.aiand.com/v1/models",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer aiand-key" }),
}),
);
const gptOss = models?.find(model => model.id === "openai/gpt-oss-120b");
expect(gptOss?.name).toBe("OpenAI GPT OSS 120B");
expect(gptOss?.reasoning).toBe(true);
expect(gptOss?.thinking?.efforts).toEqual([Effort.Low, Effort.Medium, Effort.High]);
expect(gptOss?.thinking?.defaultLevel).toBe(Effort.Medium);
expect(gptOss?.contextWindow).toBe(131072);
expect(gptOss?.cost).toEqual({ input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 });
expect(gptOss?.input).toEqual(["text"]);
const gemma = models?.find(model => model.id === "google/gemma-4-31b-it");
expect(gemma?.reasoning).toBe(false);
expect(gemma?.thinking).toBeUndefined();
expect(gemma?.input).toEqual(["text", "image"]);
});
test("ignores non-USD pricing so JPY orgs do not corrupt USD cost accounting", async () => {
const fetchMock: FetchImpl = vi.fn(async () =>
aiandModelsResponse([
{
id: "zai-org/glm-5.2",
context_window: 1000000,
capabilities: ["reasoning", "tool_calling"],
currency: "jpy",
input_per_1m: "150.000000",
output_per_1m: "600.000000",
},
]),
) as unknown as FetchImpl;
const options = aiandModelManagerOptions({ apiKey: "aiand-key", fetch: fetchMock });
const models = await options.fetchDynamicModels?.();
expect(models?.[0]?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
});
test("prefers explicit base URL over AIAND_BASE_URL and appends /v1", async () => {
Bun.env.AIAND_BASE_URL = "https://env.aiand.test";
const fetchMock: FetchImpl = vi.fn(async () =>
aiandModelsResponse([{ id: "openai/gpt-oss-120b" }]),
) as unknown as FetchImpl;
const options = aiandModelManagerOptions({
apiKey: "aiand-key",
baseUrl: "https://config.aiand.test/",
fetch: fetchMock,
});
await options.fetchDynamicModels?.();
expect(fetchMock).toHaveBeenCalledWith(
"https://config.aiand.test/v1/models",
expect.objectContaining({ method: "GET" }),
);
});
});
@@ -288,10 +288,13 @@ describe("fetchCursorUsableModels", () => {
});
it("keeps the default window for unlabeled non-max models and max-mode models outside 1M families", async () => {
// Unbundled ids: the contract under test is "no 1M signal → fallback
// preserved", so neither id may carry a bundled cursor reference whose
// snapshot window would replace the 200k default fallback.
const response = create(GetUsableModelsResponseSchema, {
models: [
create(ModelDetailsSchema, { modelId: "cursor-composer-max", maxMode: true }),
create(ModelDetailsSchema, { modelId: "claude-opus-4-8-high", displayName: "Opus 4.8" }),
create(ModelDetailsSchema, { modelId: "claude-opus-9-high", displayName: "Opus 9" }),
],
});
const defaultBaseUrl = await startCursorDiscoveryServer(toBinary(GetUsableModelsResponseSchema, response));
@@ -299,7 +302,7 @@ describe("fetchCursorUsableModels", () => {
const models = await fetchCursorUsableModels({ apiKey: "test-token", baseUrl: defaultBaseUrl, timeoutMs: 1_000 });
expect(models).toEqual([
expect.objectContaining({ id: "claude-opus-4-8-high", cursorMaxMode: false, contextWindow: 200_000 }),
expect.objectContaining({ id: "claude-opus-9-high", cursorMaxMode: false, contextWindow: 200_000 }),
expect.objectContaining({ id: "cursor-composer-max", cursorMaxMode: true, contextWindow: 200_000 }),
]);
});
@@ -154,7 +154,7 @@ describe("task.batch schema gating", () => {
expect(batch.description).toContain("`effort`");
});
it("keeps isolation boolean-only and describes the configured apply behavior", async () => {
it("keeps isolation boolean-only in the batch item schema", async () => {
mockDiscovery();
const tool = await TaskTool.create(
@@ -169,18 +169,6 @@ describe("task.batch schema gating", () => {
}
expect(isolatedSchema.type).toBe("boolean");
expect(itemProperties.apply).toBeUndefined();
expect(tool.description).toContain("automatically applied to the parent checkout");
const captureTool = await TaskTool.create(
createSession({
settings: {
"task.batch": true,
"task.isolation.mode": "auto",
"task.isolation.apply": false,
},
}),
);
expect(captureTool.description).toContain("without modifying the parent checkout");
});
it("hides isolation from the dynamic batch schema in plan mode", async () => {