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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
TZAFON_API_KEY: ${{ secrets.TZAFON_API_KEY }}
YUTORI_API_KEY: ${{ secrets.YUTORI_API_KEY }}
run: npm run test:integration --workspace @onkernel/cua-ai
Expand All @@ -152,6 +153,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
TZAFON_API_KEY: ${{ secrets.TZAFON_API_KEY }}
YUTORI_API_KEY: ${{ secrets.YUTORI_API_KEY }}
run: npm test --workspace @onkernel/cua-agent -- test/e2e.live.test.ts
1 change: 1 addition & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ If auth callbacks are omitted, both classes default to CUA env var conventions:
- OpenAI: `OPENAI_API_KEY`
- Anthropic: `ANTHROPIC_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`
- Gemini: `GOOGLE_API_KEY` or `GEMINI_API_KEY`
- OpenRouter: `OPENROUTER_API_KEY`
- Tzafon: `TZAFON_API_KEY`
- Yutori: `YUTORI_API_KEY`

Expand Down
15 changes: 15 additions & 0 deletions packages/agent/test/e2e.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type ProviderCase = {
| "openai:gpt-5.5"
| "anthropic:claude-opus-4-7"
| "google:gemini-3-flash-preview"
| "openrouter:z-ai/glm-5v-turbo"
| "tzafon:tzafon.northstar-cua-fast"
| "yutori:n1.5-latest";
prompt: string;
Expand Down Expand Up @@ -79,6 +80,20 @@ const cases: ProviderCase[] = [
timeoutMs: 300_000,
ciOptInEnvVar: "CUA_E2E_GEMINI",
},
{
name: "openrouter",
apiKeyEnvVar: "OPENROUTER_API_KEY",
modelRef: "openrouter:z-ai/glm-5v-turbo",
prompt: [
"Use the tool named `screenshot` exactly once to inspect the browser.",
"Pass empty arguments (`{}`).",
"Do not call any other tools.",
"After the tool result, provide a one-sentence summary.",
].join("\n"),
expectToolCalls: true,
timeoutMs: 180_000,
ciOptInEnvVar: "CUA_E2E_OPENROUTER",
},
{
name: "tzafon",
apiKeyEnvVar: "TZAFON_API_KEY",
Expand Down
13 changes: 8 additions & 5 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ check these environment variables, in order:
| `openai` | `OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_API_KEY` |
| `google` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |
| `openrouter` | `OPENROUTER_API_KEY` |
| `tzafon` | `TZAFON_API_KEY` |
| `yutori` | `YUTORI_API_KEY` |

Expand All @@ -38,8 +39,8 @@ Pass the resolved key as the `apiKey` stream option (as in the Quick Start
below) so a missing key fails loudly before any request is made. If you omit
`apiKey`, pi-ai's built-in providers fall back to their own env lookup
(`OPENAI_API_KEY`; `ANTHROPIC_OAUTH_TOKEN`/`ANTHROPIC_API_KEY`; for `google`
only `GEMINI_API_KEY`, not `GOOGLE_API_KEY`), and this package's Tzafon/Yutori
stream adapters read `TZAFON_API_KEY`/`YUTORI_API_KEY`.
only `GEMINI_API_KEY`, not `GOOGLE_API_KEY`; `OPENROUTER_API_KEY`), and this
package's Tzafon/Yutori stream adapters read `TZAFON_API_KEY`/`YUTORI_API_KEY`.

## Quick Start

Expand Down Expand Up @@ -220,7 +221,7 @@ list of CUA-supporting models per provider.
`CuaProvider` is the string union of provider IDs this package targets:

```ts
type CuaProvider = "openai" | "anthropic" | "google" | "tzafon" | "yutori";
type CuaProvider = "openai" | "anthropic" | "google" | "openrouter" | "tzafon" | "yutori";
```

The IDs match pi-ai's `Model.provider` values exactly. `providerForModel(model)`
Expand Down Expand Up @@ -467,8 +468,8 @@ type CuaNavigationInput = {

## Provider Namespaces

Every provider namespace (`openai`, `anthropic`, `gemini`, `tzafon`,
`yutori`) follows one convention:
Every provider namespace (`openai`, `anthropic`, `gemini`, `openrouter`,
`tzafon`, `yutori`) follows one convention:

- `computerTools(options?)` and `computerToolExecutors(options?)`
- `createActionSchema(actions?)` — TypeBox schema for the provider's subset
Expand All @@ -487,6 +488,8 @@ Provider-specific extras:
- `openai`: `openaiResponsesStoreOnPayload` payload hook, plus the
`computer_use_extra` navigation aliases `OPENAI_EXTRA_TOOL_NAME`,
`OPENAI_EXTRA_TOOL_DESCRIPTION`, `OpenAIExtraSchema`, `OpenAIExtraInput`
- `openrouter`: OpenAI-compatible chat-completions transport for routed models
such as `openrouter:z-ai/glm-5v-turbo` and `openrouter:z-ai/glm-4.6v`
- `anthropic`: `ANTHROPIC_BATCH_TOOL_NAME` (`"computer_batch"`)
- `tzafon`: the `tzafon-responses` stream adapter (`TZAFON_RESPONSES_API`,
`streamTzafonResponses`, `streamSimpleTzafonResponses`,
Expand Down
13 changes: 13 additions & 0 deletions packages/ai/docs/supported-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ Google's native `tools.computer_use` request wrapper instead.

Source: [Gemini computer use docs](https://ai.google.dev/gemini-api/docs/computer-use).

## `openrouter`

API: `openai-completions` · coordinates: pixel

Exact IDs:

- `z-ai/glm-5v-turbo` ([model page](https://openrouter.ai/z-ai/glm-5v-turbo))
- `z-ai/glm-4.6v` ([model page](https://openrouter.ai/z-ai/glm-4.6v))

OpenRouter routes these as OpenAI-compatible chat-completions models. The
model-facing CUA tools are the canonical function tools exported by this
package, not a provider-native browser action schema.

## `tzafon`

API: `tzafon-responses` · coordinates: normalized 0–999
Expand Down
1 change: 1 addition & 0 deletions packages/ai/examples/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
// Switch providers by setting CUA_MODEL (and the matching API key env var):
// anthropic:claude-opus-4-7 ANTHROPIC_API_KEY
// google:gemini-3-flash-preview GOOGLE_API_KEY
// openrouter:z-ai/glm-5v-turbo OPENROUTER_API_KEY
// tzafon:tzafon.northstar-cua-fast TZAFON_API_KEY
// yutori:n1.5-latest YUTORI_API_KEY
const modelRef = (process.env.CUA_MODEL ?? "openai:gpt-5.5") as CuaModelRef;
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const CUA_PROVIDER_API_KEY_ENV_VARS: Record<CuaProvider, readonly string[]> = {
openai: ["OPENAI_API_KEY"],
anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
tzafon: ["TZAFON_API_KEY"],
yutori: ["YUTORI_API_KEY"],
};
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./providers/common";
export * as anthropic from "./providers/anthropic/index";
export * as gemini from "./providers/gemini/index";
export * as openai from "./providers/openai/index";
export * as openrouter from "./providers/openrouter/index";
export * as tzafon from "./providers/tzafon/index";
export * as yutori from "./providers/yutori/index";

Expand Down
11 changes: 9 additions & 2 deletions packages/ai/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "@earendil-works/pi-ai";

/** Providers with curated computer-use model support. */
export type CuaProvider = "openai" | "anthropic" | "google" | "tzafon" | "yutori";
export type CuaProvider = "openai" | "anthropic" | "google" | "openrouter" | "tzafon" | "yutori";

/** Provider-qualified model reference, e.g. `"openai:gpt-5.5"` or `"google:gemini-3-flash-preview"`. */
export type CuaModelRef = `${CuaProvider}:${string}`;
Expand All @@ -23,7 +23,7 @@ export interface CuaModelInfo {
}

/** All providers this package curates computer-use models for. */
export const CUA_PROVIDERS: readonly CuaProvider[] = ["openai", "anthropic", "google", "tzafon", "yutori"];
export const CUA_PROVIDERS: readonly CuaProvider[] = ["openai", "anthropic", "google", "openrouter", "tzafon", "yutori"];

/**
* How a {@link CuaModelAnnotation} matches model ids.
Expand Down Expand Up @@ -80,6 +80,10 @@ export const CUA_MODEL_ANNOTATIONS: Record<CuaProvider, readonly CuaModelAnnotat
// gemini-3-pro-preview is intentionally absent: Google retired it and
// the API now returns 404 "model no longer available".
],
openrouter: [
{ match: { kind: "exact", id: "z-ai/glm-5v-turbo" }, source: "https://openrouter.ai/z-ai/glm-5v-turbo" },
{ match: { kind: "exact", id: "z-ai/glm-4.6v" }, source: "https://openrouter.ai/z-ai/glm-4.6v" },
],
tzafon: [
{ match: { kind: "exact", id: "tzafon.northstar-cua-fast" }, source: "https://huggingface.co/Tzafon/Northstar-CUA-Fast" },
{ match: { kind: "exact", id: "tzafon.northstar-cua-fast-1.6" }, source: "https://huggingface.co/Tzafon/Northstar-CUA-Fast" },
Expand All @@ -105,6 +109,7 @@ const CUA_MODEL_OVERRIDES: Record<CuaProvider, readonly Model<Api>[]> = {
],
anthropic: [],
google: [],
openrouter: [],
tzafon: [
cuaModel("tzafon", "tzafon.northstar-cua-fast", "Tzafon Northstar CUA Fast"),
cuaModel("tzafon", "tzafon.northstar-cua-fast-1.6", "Tzafon Northstar CUA Fast 1.6"),
Expand Down Expand Up @@ -254,6 +259,8 @@ function cuaModel(provider: CuaProvider, id: string, name: string): Model<Api> {
return { ...base, api: "anthropic-messages", baseUrl: "https://api.anthropic.com", contextWindow: 200_000, maxTokens: 64_000 } as Model<Api>;
case "google":
return { ...base, api: "google-generative-ai", baseUrl: "https://generativelanguage.googleapis.com/v1beta", contextWindow: 1_048_576, maxTokens: 65_536 } as Model<Api>;
case "openrouter":
return { ...base, api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", contextWindow: 128_000, maxTokens: 32_768 } as Model<Api>;
case "tzafon":
return { ...base, api: "tzafon-responses", baseUrl: "https://api.lightcone.ai", contextWindow: 128_000, maxTokens: 4_096 } as Model<Api>;
case "yutori":
Expand Down
108 changes: 108 additions & 0 deletions packages/ai/src/providers/openrouter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
computerTools,
createCuaActionToolExecutors,
type ComputerToolsOptions,
type CuaAction,
type CuaToolExecutorSpec,
} from "../common";
import type { ComputerToolCoordinateSystem, CuaProviderModule } from "../common";

export {
CUA_ACTION_TYPES as OPENROUTER_CUA_ACTION_TYPES,
computerTools,
createCuaActionSchema as createActionSchema,
} from "../common";
export type {
ComputerToolsOptions,
CuaAction as OpenRouterAction,
} from "../common";

// OpenRouter exposes the GLM V models through its OpenAI-compatible Chat
// Completions API. The model-specific Z.AI docs describe function calling, but
// not a provider-native browser coordinate system, so CUA uses screenshot pixel
// coordinates with the standard canonical action tools.
export function coordinateSystem(): ComputerToolCoordinateSystem {
return { type: "pixel" };
}

export const OPENROUTER_COMPUTER_INSTRUCTIONS = `You control a Kernel cloud browser through individual browser tools. Use screenshot pixel coordinates for tool calls, and request screenshots or URL reads when state changes.`;

export function buildOpenRouterSystemPrompt(opts: { suffix?: string } = {}): string {
return [OPENROUTER_COMPUTER_INSTRUCTIONS, opts.suffix].filter(Boolean).join("\n\n");
}

/** Build OpenRouter execution adapters and normalize GLM tuple-shaped coordinates before browser execution. */
export function computerToolExecutors(options: ComputerToolsOptions = {}): CuaToolExecutorSpec[] {
return createCuaActionToolExecutors(options.actions).map((executor) => ({
...executor,
toActions(args: unknown): CuaAction[] {
return executor.toActions(args).map(normalizeOpenRouterAction);
},
}));
}

/** Normalize OpenRouter GLM-V coordinate arguments into canonical CUA numeric coordinates. */
export function normalizeOpenRouterAction(action: CuaAction): CuaAction {
Comment thread
cursor[bot] marked this conversation as resolved.
switch (action.type) {
case "click":
case "double_click":
case "mouse_down":
case "mouse_up":
case "move": {
const { x, y, ...rest } = action;
return { ...rest, ...normalizeRequiredPoint(action.type, x, y) };
}
case "scroll": {
const { x, y, ...rest } = action;
return { ...rest, ...normalizeOptionalPoint(action.type, x, y) };
}
case "drag":
return {
...action,
path: action.path.map((point) => normalizeRequiredPoint("drag path", point.x, point.y)),
};
default:
return action;
}
}

function normalizeRequiredPoint(label: string, xValue: unknown, yValue: unknown): { x: number; y: number } {
const x = coordinateNumber(xValue, 0);
const y = coordinateNumber(yValue, 1) ?? coordinateTupleNumber(xValue, 1);
if (x === undefined || y === undefined) throw new Error(`invalid OpenRouter ${label} coordinates`);
return { x, y };
}

function normalizeOptionalPoint(label: string, xValue: unknown, yValue: unknown): { x?: number; y?: number } {
const x = coordinateNumber(xValue, 0);
const yFromTuple = coordinateTupleNumber(xValue, 1);
const y = coordinateNumber(yValue, 1) ?? yFromTuple;
if (xValue !== undefined && x === undefined) throw new Error(`invalid OpenRouter ${label} x coordinate`);
if ((yValue !== undefined || yFromTuple !== undefined) && y === undefined) throw new Error(`invalid OpenRouter ${label} y coordinate`);
return {
...(x !== undefined ? { x } : {}),
...(y !== undefined ? { y } : {}),
};
}

function coordinateNumber(value: unknown, index: number): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (Array.isArray(value)) {
const next = value[index] ?? value[0];
return typeof next === "number" && Number.isFinite(next) ? next : undefined;
}
return undefined;
}

function coordinateTupleNumber(value: unknown, index: number): number | undefined {
if (!Array.isArray(value)) return undefined;
const next = value[index];
return typeof next === "number" && Number.isFinite(next) ? next : undefined;
}

export const providerModule = {
toolDefinitions: computerTools,
toolExecutors: computerToolExecutors,
coordinateSystem,
buildSystemPrompt: buildOpenRouterSystemPrompt,
} satisfies CuaProviderModule;
2 changes: 2 additions & 0 deletions packages/ai/src/runtime-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getCuaModel, providerForModel } from "./models";
import { providerModule as anthropic } from "./providers/anthropic/index";
import { providerModule as gemini } from "./providers/gemini/index";
import { providerModule as openai } from "./providers/openai/index";
import { providerModule as openrouter } from "./providers/openrouter/index";
import { providerModule as tzafon } from "./providers/tzafon/index";
import { providerModule as yutori } from "./providers/yutori/index";
import type {
Expand All @@ -16,6 +17,7 @@ const PROVIDERS = {
openai,
anthropic,
google: gemini,
openrouter,
tzafon,
yutori,
} satisfies Record<CuaProvider, CuaProviderModule>;
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/test/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ENV_KEYS = [
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"OPENROUTER_API_KEY",
"TZAFON_API_KEY",
"YUTORI_API_KEY",
] as const;
Expand All @@ -29,12 +30,14 @@ afterEach(() => {
describe("cua api key helpers", () => {
it("maps provider names to expected environment variables", () => {
expect(cuaApiKeyEnvVarsForProvider("openai")).toEqual(["OPENAI_API_KEY"]);
expect(cuaApiKeyEnvVarsForProvider("openrouter")).toEqual(["OPENROUTER_API_KEY"]);
expect(cuaApiKeyEnvVarsForProvider("google")).toEqual(["GOOGLE_API_KEY", "GEMINI_API_KEY"]);
expect(cuaApiKeyEnvVarsForProvider("gemini")).toEqual(["GOOGLE_API_KEY", "GEMINI_API_KEY"]);
expect(cuaApiKeyEnvVarsForProvider("unknown")).toEqual([]);
});

it("resolves provider api keys with fallback order", () => {
delete process.env.GOOGLE_API_KEY;
process.env.GEMINI_API_KEY = "gemini";
expect(getCuaEnvApiKey("google")).toBe("gemini");
process.env.GOOGLE_API_KEY = "google";
Expand Down
Loading
Loading