Skip to content
Merged
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
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencomputer/cli",
"version": "0.7.1",
"version": "0.7.2",
"description": "Build, test, deploy, and share OpenComputer agents as code.",
"type": "module",
"bin": {
Expand Down
3 changes: 3 additions & 0 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface ManagedAgentDeployment {
projectDeploymentId?: string;
localAgentId?: string;
createdAt: string;
models?: Array<{ provider: string; model: string }>;
defaultModel?: { provider: string; model: string };
}

export interface ManagedAgentEvent {
Expand Down Expand Up @@ -964,6 +966,7 @@ export class OpenComputerClient {
redirectOrigins?: Array<{ origin: string; pathPrefix?: string }>;
}>;
memory: MemoryDeclaration[];
models: Array<{ provider: string; model: string }>;
projectDeployment?: {
id: string;
digest: string;
Expand Down
3 changes: 3 additions & 0 deletions cli/src/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ test("development publish builds an immutable artifact under the development ali
assert.equal(input?.agentId, "hello-agent");
assert.equal(input?.alias, "development");
assert.equal(input?.source.digest, result.built.digest);
assert.deepEqual(input?.models, [
{ provider: "openrouter", model: "anthropic/claude-sonnet-4.6" },
]);
assert.match(result.deployment.id, /^hello-agent:[a-f0-9]{64}$/);
} finally {
await rm(parent, { recursive: true, force: true });
Expand Down
1 change: 1 addition & 0 deletions cli/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function registerBuiltDeployment(
connections: built.connections,
httpConnections: built.httpConnections,
memory: built.memory,
models: built.models,
...(projectDeployment ? { projectDeployment } : {}),
source: {
digest: built.digest,
Expand Down
53 changes: 53 additions & 0 deletions cli/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,59 @@ export default function Agent() {
}
});

test("the compiler enumerates literal model selections in a conditional", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-model-conditional-"));
try {
const initialized = await initializeAgentProject(resolve(parent, "app"));
await writeFile(
resolve(initialized.agentRoot, "agent.ts"),
`import { useInput, useModel } from "@opencomputer/agent";
export default function Agent() {
const input = useInput();
useModel(input.text?.includes("hard")
? "anthropic/claude-sonnet-5"
: "anthropic/claude-haiku-4.5");
return "Help with the request.";
}
`,
);

const runtime = await prepareAgent(initialized.agentRoot);
const manifest = JSON.parse(
await readFile(resolve(runtime, ".opencomputer", "reactive.json"), "utf8"),
) as { models: Array<{ provider: string; model: string }> };
assert.deepEqual(manifest.models, [
{ provider: "openrouter", model: "anthropic/claude-haiku-4.5" },
{ provider: "openrouter", model: "anthropic/claude-sonnet-5" },
]);
} finally {
await rm(parent, { recursive: true, force: true });
}
});

test("the compiler rejects a model selection it cannot register", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-model-dynamic-"));
try {
const initialized = await initializeAgentProject(resolve(parent, "app"));
await writeFile(
resolve(initialized.agentRoot, "agent.ts"),
`import { useModel } from "@opencomputer/agent";
const model = "anthropic/claude-sonnet-5";
export default function Agent() {
useModel(model);
return "Help with the request.";
}
`,
);
await assert.rejects(
prepareAgent(initialized.agentRoot),
/useModel\(\) must use a literal model selection/,
);
} finally {
await rm(parent, { recursive: true, force: true });
}
});

test("the compiler records secret-backed HTTP connections without secret values", async () => {
const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-egress-"));
const root = resolve(parent, "app");
Expand Down
69 changes: 53 additions & 16 deletions cli/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface BuiltAgentArtifact {
connections: string[];
httpConnections: HttpConnectionManifest[];
memory: MemoryDeclaration[];
models: Array<{ provider: string; model: string }>;
body: Buffer;
digest: string;
elapsedMs: number;
Expand Down Expand Up @@ -2297,30 +2298,59 @@ function staticModelSelections(
ts.ScriptKind.TS,
);
const selections: Array<{ provider: string; model: string }> = [];
const selectionValues = (
value: ts.Expression,
): Array<{ provider: string; model: string }> | undefined => {
if (ts.isStringLiteralLike(value)) {
return [{ provider: "openrouter", model: value.text }];
}
if (ts.isObjectLiteralExpression(value)) {
let provider: string | undefined;
let model: string | undefined;
for (const property of value.properties) {
if (!ts.isPropertyAssignment(property)) continue;
const name = ts.isIdentifier(property.name)
? property.name.text
: ts.isStringLiteralLike(property.name)
? property.name.text
: undefined;
if (!name || !ts.isStringLiteralLike(property.initializer)) continue;
if (name === "provider") provider = property.initializer.text;
if (name === "model") model = property.initializer.text;
}
return provider && model ? [{ provider, model }] : undefined;
}
if (ts.isConditionalExpression(value)) {
const whenTrue = selectionValues(value.whenTrue);
const whenFalse = selectionValues(value.whenFalse);
return whenTrue && whenFalse ? [...whenTrue, ...whenFalse] : undefined;
}
if (
ts.isParenthesizedExpression(value) ||
ts.isAsExpression(value) ||
ts.isTypeAssertionExpression(value) ||
ts.isSatisfiesExpression(value) ||
ts.isNonNullExpression(value)
) {
return selectionValues(value.expression);
}
return undefined;
};
const visit = (node: ts.Node): void => {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "useModel"
) {
const value = node.arguments[0];
if (value && ts.isStringLiteralLike(value)) {
selections.push({ provider: "openrouter", model: value.text });
} else if (value && ts.isObjectLiteralExpression(value)) {
let provider: string | undefined;
let model: string | undefined;
for (const property of value.properties) {
if (!ts.isPropertyAssignment(property)) continue;
const name = ts.isIdentifier(property.name)
? property.name.text
: ts.isStringLiteralLike(property.name)
? property.name.text
: undefined;
if (!name || !ts.isStringLiteralLike(property.initializer)) continue;
if (name === "provider") provider = property.initializer.text;
if (name === "model") model = property.initializer.text;
if (value) {
const values = selectionValues(value);
if (!values) {
throw new Error(
"useModel() must use a literal model selection or a conditional whose branches are literal selections",
);
}
if (provider && model) selections.push({ provider, model });
selections.push(...values);
}
}
ts.forEachChild(node, visit);
Expand All @@ -2333,6 +2363,10 @@ function staticModelSelections(
candidate.provider === selection.provider &&
candidate.model === selection.model,
) === index,
).sort((left, right) =>
`${left.provider}/${left.model}`.localeCompare(
`${right.provider}/${right.model}`,
),
);
}

Expand Down Expand Up @@ -2815,10 +2849,12 @@ export async function buildAgentArtifact(
connections?: string[];
httpConnections?: HttpConnectionManifest[];
memory?: MemoryDeclaration[];
models?: Array<{ provider: string; model: string }>;
};
const connections = [...new Set(reactive.connections ?? [])].sort();
const httpConnections = reactive.httpConnections ?? [];
const memory = reactive.memory ?? [];
const models = reactive.models ?? [];
const body = Buffer.from(
JSON.stringify({
version: 1,
Expand All @@ -2833,6 +2869,7 @@ export async function buildAgentArtifact(
connections,
httpConnections,
memory,
models,
body,
digest: createHash("sha256").update(body).digest("hex"),
elapsedMs: Math.round(performance.now() - startedAt),
Expand Down
70 changes: 69 additions & 1 deletion cloudflare-workers/api-edge/src/managed_agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,44 @@ describe("managed agents proxy", () => {
});
});

it("preserves the unavailable model id in deployment validation errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json(
{
error: {
code: "invalid_model_selection",
message:
'Model "openrouter/anthropic/claude-sonnet-999" is not available in the OpenRouter catalog',
},
},
{ status: 409 },
),
),
);

const response = await proxyManagedAgents(
new Request(
"https://app.opencomputer.dev/api/managed-agents/deployments/test",
),
{
OC_MANAGED_AGENTS_SECRET: "test-secret",
MANAGED_AGENTS_API_URL: "https://managedagents.test",
},
{ orgID: "org_test", userID: "user_test" },
"/api/managed-agents",
);
expect(response.status).toBe(409);
await expect(response.json()).resolves.toEqual({
error: {
code: "invalid_model_selection",
message:
'Model "openrouter/anthropic/claude-sonnet-999" is not available in the OpenRouter catalog',
},
});
});

it("does not expose arbitrary private backend routes", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
Expand All @@ -1348,7 +1386,25 @@ describe("managed agents proxy", () => {
});

it("uploads source without exposing provider details to the CLI", async () => {
const source = JSON.stringify({ version: 1, files: [] });
const source = JSON.stringify({
version: 1,
files: [
{
path: ".opencomputer/reactive.json",
content: btoa(
JSON.stringify({
version: 2,
models: [
{
provider: "openrouter",
model: "anthropic/claude-sonnet-5",
},
],
}),
),
},
],
});
const digestBytes = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(source),
Expand Down Expand Up @@ -1435,6 +1491,12 @@ describe("managed agents proxy", () => {
provider: { kind: "document", maxBytes: 8192 },
},
],
models: [
{
provider: "openrouter",
model: "anthropic/claude-sonnet-5",
},
],
source: {
digest,
size: source.length,
Expand Down Expand Up @@ -1480,6 +1542,12 @@ describe("managed agents proxy", () => {
provider: { kind: "document", maxBytes: 8192 },
},
],
models: [
{
provider: "openrouter",
model: "anthropic/claude-sonnet-5",
},
],
});
expect(JSON.stringify(await response.json())).not.toMatch(
/bucket|imageArn|arn:aws|uploads\.test/i,
Expand Down
Loading
Loading