diff --git a/shim/codex-oauth.mjs b/shim/codex-oauth.mjs index c9283d3..d963f28 100644 --- a/shim/codex-oauth.mjs +++ b/shim/codex-oauth.mjs @@ -7,7 +7,7 @@ import os from "node:os"; import path from "node:path"; import { frames, resolveEntry } from "./inference.mjs"; -import { structToJson, valueToJson } from "./struct.mjs"; +import { structToJson, structToJsonSchema, valueToJson } from "./struct.mjs"; const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OAUTH_TOKEN_URL = process.env.CODEX_OAUTH_TOKEN_URL ?? "https://auth.openai.com/oauth/token"; @@ -255,7 +255,7 @@ function buildRequestBody(req, entry, { withReasoningHistory }) { name: t.name, description: t.description ?? "", strict: false, - parameters: structToJson(t.parameters) ?? { type: "object", properties: {} }, + parameters: structToJsonSchema(t.parameters) ?? { type: "object", properties: {} }, })); const body = { diff --git a/shim/inference.mjs b/shim/inference.mjs index 3551929..b771cf8 100644 --- a/shim/inference.mjs +++ b/shim/inference.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import protobuf from "protobufjs"; -import { structToJson } from "./struct.mjs"; +import { structToJson, structToJsonSchema, valueToJson } from "./struct.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -106,30 +106,76 @@ function sseLines(chunkText, leftover) { return { lines: lines.slice(0, -1), leftover: lines.at(-1) }; } +function messageText(message) { + if (message.content === "text") return message.text ?? ""; + if (message.content === "parts") { + return (message.parts?.parts ?? []) + .map((part) => (part.part === "text" ? part.text?.text ?? "" : "")) + .join(""); + } + return ""; +} + +function toolResultText(part) { + const value = valueToJson(part.result); + const text = typeof value === "string" ? value : JSON.stringify(value ?? null); + return part.isError ? `ERROR: ${text}` : text; +} + +function openAiMessages(messages) { + const out = []; + for (const message of messages ?? []) { + if (message.role === 2) { + const content = messageText(message); + const toolCalls = (message.toolCalls ?? []) + .filter((call) => call.toolCallId && call.toolName) + .map((call) => ({ + id: call.toolCallId, + type: "function", + function: { + name: call.toolName, + arguments: call.rawToolCallArgs || JSON.stringify(structToJson(call.args) ?? {}), + }, + })); + if (content || toolCalls.length) { + out.push({ + role: "assistant", + content: content || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }); + } + continue; + } + if (message.role === 3) { + for (const part of message.toolContent?.parts ?? []) { + if (!part.toolCallId) continue; + out.push({ + role: "tool", + tool_call_id: part.toolCallId, + content: toolResultText(part), + }); + } + continue; + } + const content = messageText(message); + if (content) out.push({ role: message.role === 1 ? "user" : "system", content }); + } + return out; +} + async function* openaiSession(req, entry) { const apiKey = entry.api_key ?? (entry.env_key ? process.env[entry.env_key] : undefined); if (!apiKey) { yield frames.error(`shim: no API key for model entry (set ${entry.env_key ?? "api_key"})`, 5); return; } - const messages = []; - for (const m of req.messages) { - const role = m.role === 1 ? "user" : m.role === 2 ? "assistant" : m.role === 3 ? "tool" : "system"; - let content = ""; - if (m.content === "text") content = m.text ?? ""; - else if (m.content === "parts") { - content = (m.parts.parts ?? []) - .map((p) => (p.part === "text" ? p.text?.text ?? "" : "")) - .join(""); - } - if (content) messages.push({ role, content }); - } + const messages = openAiMessages(req.messages); const tools = (req.tools ?? []).map((t) => ({ type: "function", function: { name: t.name, description: t.description ?? "", - parameters: structToJson(t.parameters) ?? { type: "object", properties: {} }, + parameters: structToJsonSchema(t.parameters) ?? { type: "object", properties: {} }, }, })); const body = { diff --git a/shim/struct.mjs b/shim/struct.mjs index 198b39c..5fcd86d 100644 --- a/shim/struct.mjs +++ b/shim/struct.mjs @@ -48,6 +48,13 @@ export function structToJson(struct) { return out; } +export function structToJsonSchema(struct) { + const value = structToJson(struct); + const nested = value?.jsonSchema; + if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested; + return value; +} + export function jsonToValue(value) { if (value === null || value === undefined) return { nullValue: 0 }; if (typeof value === "number") return { numberValue: value }; diff --git a/shimctl.sh b/shimctl.sh index a609676..64c2692 100755 --- a/shimctl.sh +++ b/shimctl.sh @@ -9,6 +9,22 @@ mkdir -p "$ROOT/logs" "$ROOT/state" is_up() { curl -sk --max-time 2 https://localhost:8443/health >/dev/null 2>&1; } container_mode() { [[ "$(uname -s)" == "Darwin" && -n "${GROKBOT_RUNTIME_DIR:-}" ]]; } +provider_env_keys() { + node - "$ROOT/models.json" <<'NODE' +const fs = require("node:fs"); + +const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const seen = new Set(); +for (const entry of Object.values(config.models ?? {})) { + const key = entry?.env_key; + if (typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (process.env[key] === undefined || seen.has(key)) continue; + seen.add(key); + process.stdout.write(`${key}\n`); +} +NODE +} + stop() { if container_mode; then docker exec "${GROKBOT_COMPUTER_CONTAINER:-grokbot-computer}" pkill -f '/grokbot-shim/shim/server.mjs' 2>/dev/null || true @@ -33,12 +49,19 @@ stop() { start() { if container_mode; then + local provider_keys + local -a provider_args=() + provider_keys="$(provider_env_keys)" + while IFS= read -r key; do + [[ -n "$key" ]] && provider_args+=(-e "$key") + done <<<"$provider_keys" docker exec -d \ -u "$(id -u):$(id -g)" \ -w /grokbot-shim \ -e ELECTRON_RUN_AS_NODE=1 \ -e BIND_HOST=0.0.0.0 \ -e CODEX_AUTH_FILE=/codex/auth.json \ + "${provider_args[@]}" \ "${GROKBOT_COMPUTER_CONTAINER:-grokbot-computer}" \ "${GROKBOT_HOST_APP:-/opt/Grok Bot/sand}" /grokbot-shim/shim/server.mjs for _ in $(seq 1 20); do is_up && break; sleep 0.25; done diff --git a/test/inference.test.mjs b/test/inference.test.mjs new file mode 100644 index 0000000..a7d9c6e --- /dev/null +++ b/test/inference.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runEntry } from "../shim/inference.mjs"; +import { jsonToStruct, jsonToValue } from "../shim/struct.mjs"; + +async function captureOpenAiRequest(t, request) { + const originalFetch = globalThis.fetch; + let captured; + globalThis.fetch = async (url, options) => { + captured = { url: String(url), body: JSON.parse(options.body) }; + return new Response( + 'data: {"id":"test-response","model":"test-model","choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const entry = { + provider: "openai-compatible", + base_url: "https://provider.example/v1", + model: "test-model", + api_key: "fixture-key", + }; + for await (const _frame of runEntry(request, entry)) { + // Consume the real adapter stream so the outbound request is completed. + } + return captured; +} + +test("OpenAI-compatible tools unwrap Grok's nested JSON Schema", async (t) => { + const nestedSchema = { + type: "object", + properties: { pattern: { type: "string" } }, + }; + const captured = await captureOpenAiRequest(t, { + messages: [], + tools: [ + { + name: "AwaitExternalShell", + description: "Wait for output", + parameters: jsonToStruct({ jsonSchema: nestedSchema }), + }, + ], + modelConfig: {}, + }); + + assert.deepEqual(captured.body.tools[0].function.parameters, nestedSchema); +}); + +test("OpenAI-compatible history preserves tool calls and results", async (t) => { + const captured = await captureOpenAiRequest(t, { + messages: [ + { role: 1, content: "text", text: "Open app.radprimer.com" }, + { + role: 2, + toolCalls: [ + { + toolCallId: "call-browser-1", + toolName: "OpenBrowser", + rawToolCallArgs: '{"url":"https://app.radprimer.com"}', + }, + ], + }, + { + role: 3, + content: "toolContent", + toolContent: { + parts: [ + { + toolCallId: "call-browser-1", + toolName: "OpenBrowser", + result: jsonToValue({ opened: true }), + isError: false, + }, + ], + }, + }, + ], + tools: [], + modelConfig: {}, + }); + + assert.deepEqual(captured.body.messages, [ + { role: "user", content: "Open app.radprimer.com" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call-browser-1", + type: "function", + function: { + name: "OpenBrowser", + arguments: '{"url":"https://app.radprimer.com"}', + }, + }, + ], + }, + { role: "tool", tool_call_id: "call-browser-1", content: '{"opened":true}' }, + ]); +}); diff --git a/test/models.test.mjs b/test/models.test.mjs index c199913..8f5862a 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -9,22 +9,23 @@ import { } from "../shim/models.mjs"; import { statsigBootstrap } from "../shim/statsig.mjs"; +const modelConfig = JSON.parse(fs.readFileSync(new URL("../models.json", import.meta.url), "utf8")); + test("the model catalog exposes the configured default", () => { const payload = availableModelsPayload(); - assert.equal(payload.composerModelConfig.defaultModel, "GPT-5.6-Luna (Codex)"); - assert.ok(payload.models.some((model) => model.name === "GPT-5.6-Luna (Codex)")); + assert.equal(payload.composerModelConfig.defaultModel, modelConfig.default); + assert.ok(payload.models.some((model) => model.name === modelConfig.default && model.defaultOn)); assert.ok(payload.models.every((model) => model.visibleInRoutedModelView === false)); }); test("the model catalog survives protobuf encoding", () => { const decoded = decodeAvailableModels(encodeAvailableModels()); assert.equal(decoded.models.length, availableModelsPayload().models.length); - assert.equal(decoded.composerModelConfig.defaultModel, "GPT-5.6-Luna (Codex)"); + assert.equal(decoded.composerModelConfig.defaultModel, modelConfig.default); }); test("tracked model configuration contains no inline API keys", () => { - const config = JSON.parse(fs.readFileSync(new URL("../models.json", import.meta.url), "utf8")); - for (const entry of Object.values(config.models)) { + for (const entry of Object.values(modelConfig.models)) { assert.equal("api_key" in entry, false); } }); diff --git a/test/shimctl.test.mjs b/test/shimctl.test.mjs new file mode 100644 index 0000000..81a22c0 --- /dev/null +++ b/test/shimctl.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const ROOT = path.resolve(new URL("..", import.meta.url).pathname); + +function writeExecutable(file, contents) { + fs.writeFileSync(file, contents, { mode: 0o755 }); +} + +test("container shim start forwards only configured provider environment names", (t) => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "grokbot-shimctl-test-")); + t.after(() => fs.rmSync(fixture, { recursive: true, force: true })); + + fs.mkdirSync(path.join(fixture, "scripts")); + fs.copyFileSync(path.join(ROOT, "shimctl.sh"), path.join(fixture, "shimctl.sh")); + fs.copyFileSync(path.join(ROOT, "scripts", "load-env.sh"), path.join(fixture, "scripts", "load-env.sh")); + fs.writeFileSync( + path.join(fixture, ".env"), + [ + "GROKBOT_RUNTIME_DIR=/tmp/grokbot-runtime", + 'GROKBOT_HOST_APP="/opt/Grok Bot/grok-bot"', + "CUSTOM_PROVIDER_TOKEN=fixture-secret", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(fixture, "models.json"), + JSON.stringify({ + models: { + first: { provider: "openai-compatible", env_key: "CUSTOM_PROVIDER_TOKEN" }, + duplicate: { provider: "openai-compatible", env_key: "CUSTOM_PROVIDER_TOKEN" }, + missing: { provider: "openai-compatible", env_key: "MISSING_PROVIDER_TOKEN" }, + invalid: { provider: "openai-compatible", env_key: "INVALID-NAME" }, + }, + }), + ); + + const fakeBin = path.join(fixture, "bin"); + fs.mkdirSync(fakeBin); + const dockerArgsFile = path.join(fixture, "docker-args.txt"); + writeExecutable(path.join(fakeBin, "uname"), "#!/bin/sh\necho Darwin\n"); + writeExecutable(path.join(fakeBin, "curl"), "#!/bin/sh\nexit 0\n"); + writeExecutable( + path.join(fakeBin, "docker"), + '#!/bin/sh\nprintf "%s\\n" "$@" > "$DOCKER_ARGS_FILE"\n', + ); + + execFileSync("bash", [path.join(fixture, "shimctl.sh"), "start"], { + env: { + ...process.env, + DOCKER_ARGS_FILE: dockerArgsFile, + PATH: `${fakeBin}:${process.env.PATH}`, + }, + }); + + const args = fs.readFileSync(dockerArgsFile, "utf8").trim().split("\n"); + assert.equal(args.filter((arg) => arg === "CUSTOM_PROVIDER_TOKEN").length, 1); + assert.equal(args.includes("MISSING_PROVIDER_TOKEN"), false); + assert.equal(args.includes("INVALID-NAME"), false); + assert.equal(args.some((arg) => arg.includes("fixture-secret")), false); +});