From f963bf9b3d132cb6a140e264806488ba8455ddec Mon Sep 17 00:00:00 2001 From: lintonggeng <15643640+genglintong@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:25:53 +0800 Subject: [PATCH 1/2] feat: add OpenAI Decisions System One provider --- packages/openai-decisions/CHANGELOG.md | 8 + packages/openai-decisions/LICENSE | 22 + packages/openai-decisions/NOTICE.md | 10 + packages/openai-decisions/README.md | 150 +++++ packages/openai-decisions/VERIFICATION.md | 41 ++ packages/openai-decisions/adapter.mjs | 178 ++++++ packages/openai-decisions/credentials.mjs | 33 + packages/openai-decisions/index.mjs | 48 ++ packages/openai-decisions/opencode.mjs | 1 + packages/openai-decisions/opencode2.mjs | 594 ++++++++++++++++++ packages/openai-decisions/package.json | 15 + .../openai-decisions/test/adapter.test.mjs | 170 +++++ .../openai-decisions/test/plugin.test.mjs | 60 ++ registry.json | 15 +- 14 files changed, 1344 insertions(+), 1 deletion(-) create mode 100644 packages/openai-decisions/CHANGELOG.md create mode 100644 packages/openai-decisions/LICENSE create mode 100644 packages/openai-decisions/NOTICE.md create mode 100644 packages/openai-decisions/README.md create mode 100644 packages/openai-decisions/VERIFICATION.md create mode 100644 packages/openai-decisions/adapter.mjs create mode 100644 packages/openai-decisions/credentials.mjs create mode 100644 packages/openai-decisions/index.mjs create mode 100644 packages/openai-decisions/opencode.mjs create mode 100644 packages/openai-decisions/opencode2.mjs create mode 100644 packages/openai-decisions/package.json create mode 100644 packages/openai-decisions/test/adapter.test.mjs create mode 100644 packages/openai-decisions/test/plugin.test.mjs diff --git a/packages/openai-decisions/CHANGELOG.md b/packages/openai-decisions/CHANGELOG.md new file mode 100644 index 000000000..1ea3dfb31 --- /dev/null +++ b/packages/openai-decisions/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0 + +- Add a native Magpie decision provider for OpenAI Decisions. +- Convert System One choice, score and noul questions while retaining answer probabilities and provider confidence. +- Support API key sign-in and optional reuse of Magpie's saved official OpenAI provider. +- Preserve upstream errors and cancellation; reject unsupported request shapes explicitly. diff --git a/packages/openai-decisions/LICENSE b/packages/openai-decisions/LICENSE new file mode 100644 index 000000000..cb62530d4 --- /dev/null +++ b/packages/openai-decisions/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 lintonggeng +Copyright (c) 2026 magpie-community contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/openai-decisions/NOTICE.md b/packages/openai-decisions/NOTICE.md new file mode 100644 index 000000000..38b15b823 --- /dev/null +++ b/packages/openai-decisions/NOTICE.md @@ -0,0 +1,10 @@ +The OpenCode 2.x compatibility bridge, `opencode2.mjs`, is an unmodified +copy of `scripts/opencode2.mjs` from magpie-community/plugins, distributed +under that repository's MIT license. Keep it byte-identical to that copy. + +Upstream: https://github.com/magpie-community/plugins + +The implementation follows the public provider/auth/decision contracts. +Reference implementations: the community River provider, AgentCompass's +decision-provider registration, and lesnolie/magpie-free-gateways' System +One integration. Their application logic is not bundled here. diff --git a/packages/openai-decisions/README.md b/packages/openai-decisions/README.md new file mode 100644 index 000000000..5c6bf3007 --- /dev/null +++ b/packages/openai-decisions/README.md @@ -0,0 +1,150 @@ +# OpenAI Decisions for Magpie + +A native Magpie provider exposing OpenAI's **Decisions API** through +Magpie's existing **System One** endpoint. The model is +`openai-decisions/gpt-6-luna`; use it as a routing group's classifier or a +member of a decision-model group alongside other System One providers. + +This package is proposed for `magpie-community/plugins`. +Install from a local checkout while community review and publication are pending. + +## Why this plugin + +Magpie already speaks System One for compatible decision providers. Add +those directly with their decision endpoint and model IDs. OpenAI +Decisions uses a different path and request/answer schema; this plugin +converts that protocol while Magpie manages accounts, routing, rate +limits, failover and request records. + +The built-in OpenAI provider handles normal Chat/Responses calls. This +provider calls `POST https://api.openai.com/v1/decisions` and retains native +decision probabilities. It does not simulate decisions with chat text. +The [upstream feature request](https://github.com/yetone/magpie/issues/4316) +describes the gap this fills. + +## Install and sign in + +```sh +magpie plugin add /absolute/path/to/magpie-openai-decisions +magpie plugin login openai-decisions +``` + +Two sign-in methods: + +1. **OpenAI API key**: Magpie's regular API-key sign-in and account store. +2. **Use the OpenAI key already saved in Magpie**: explicitly reuse the + primary key of the enabled official provider whose ID is `openai`. + The sign-in record stores a provider reference; each request reads the + current saved key. This optional method follows `XDG_CONFIG_HOME` and + Magpie's usual config directory. + +Direct key sign-in works without a separate OpenAI provider. The package +follows the community's OpenCode 1.x/Magpie and OpenCode 2.x loader layout; +ordinary chat clients still need an explicit decision call to use it. + +## One endpoint + +Send a System One request to `http://127.0.0.1:3425/v1/systemone`: + +```json +{ + "model": "openai-decisions/gpt-6-luna", + "state": {"message": "What does HTTP 404 mean?"}, + "questions": { + "intent": { + "type": "choice", + "instructions": "Which kind of request is this?", + "criteria": {"question": "Asks for an explanation", "code_change": "Asks to modify code"} + }, + "is_question": {"type": "noul", "instructions": "Does this ask a question?"}, + "effort": { + "type": "score", + "instructions": "How much work does this request require?", + "criteria": ["Little work", "Some work", "Much work", "Most work"] + } + } +} +``` + +With an existing TypeSafe provider, create a decision group: + +```sh +magpie group add Decisions models=typesafe/jev-latest,openai-decisions/gpt-6-luna routing=order +``` + +Then use `"model":"group/decisions"`. Magpie's group order and failover +decide which provider answers. A chat routing group can choose either the +decision provider or the decision group as its classifier. + +## Protocol and limits + +| System One | OpenAI Decisions | System One answer | +| --- | --- | --- | +| Text `state` | Text `input` | Shared evidence | +| Object/array `state` | User message containing `input_json` | Shared evidence | +| Numeric/boolean `state` | JSON text `input` | Shared evidence | +| `choice` with `criteria` map | `choice` with `choices` array | Selected value, probability map, confidence | +| `score` with ordered `criteria` | `score` with `levels` array | Expected zero-based score, legend, probability map, confidence | +| `noul` with optional true/false definitions | `predicate` | Probability in `noul` | + +Structured instructions and descriptions become compact JSON. Predicate +definitions and score-level ordering are retained, including numeric and +structured descriptions. This version accepts text and ordinary JSON, +and declares no image capability. Limits: 1–400 questions, 2–255 choice +options, 2–10 ordered score levels. Unsupported fields and `box` are +rejected explicitly; compressed requests are not implemented. + +Probabilities and OpenAI's confidence values are retained. Magpie derives +its own routing confidence from option probabilities; vendor confidence +fields may use different calibration, so equal raw values across +providers do not imply equal reliability. + +Upstream error statuses, error bodies, retry/rate-limit headers and request +IDs are preserved. Invalid or incomplete successful answers become 502. +Cancellation propagates upstream. Calls use the fixed OpenAI Decisions +endpoint, a fresh header set and no redirects. + +## Develop and contribute + +```sh +npm test +``` + +Tests cover conversion, structured questions, answer matching, invalid +distributions, errors, cancellation and credential selection. Live Magpie +tests are separate; fixtures are not evidence of account access. +See [VERIFICATION.md](VERIFICATION.md) for the real gateway checks and limits. + +From the root of the community repository, run: + +```sh +bun scripts/check.mjs openai-decisions +bun test packages/openai-decisions +``` + +Keep `opencode2.mjs` byte-identical to the community's canonical +`scripts/opencode2.mjs`. See [NOTICE.md](NOTICE.md) for attribution. MIT license. + +Review checks for protocol changes: + +- Keep object/array evidence as `input_json`; serialize primitive evidence as text. +- Preserve predicate definitions, ordered score criteria and vendor probabilities. +- Distinguish transport success from complete, valid decision answers. +- Verify a real classifier call as well as the downstream chat response. +- Treat HTTP 429 as a quota/rate-limit outcome, not proof of an unsupported model. + Record actual token usage and quota units, then pace live checks by that budget. +- Keep credentials, internal endpoints and local verification artifacts outside the distributable. + +References: [OpenAI Decisions](https://developers.openai.com/api/docs/guides/decisions), +[Magpie plugins](https://github.com/yetone/magpie/blob/v0.1.3039/docs/subsystems/provider-plugins.md), +[River](https://github.com/magpie-community/plugins/tree/main/packages/river), +[System One example](https://github.com/lesnolie/magpie-free-gateways). + +## 中文 + +统一入口复用 Magpie `/v1/systemone`:兼容 System One 的供应商直接接入, +本插件负责转换 OpenAI Decisions。支持选择、评分、是非概率,保留结构化问题、 +概率、置信度、用量、错误及取消行为,可用于决策模型组和意图路由。 + +登录可使用独立 API key,也可明确选择复用 Magpie 已保存的官方 OpenAI +提供方。社区代码使用公开协议与通用示例;组织内部端点和凭据由本地维护。 diff --git a/packages/openai-decisions/VERIFICATION.md b/packages/openai-decisions/VERIFICATION.md new file mode 100644 index 000000000..fca6635bc --- /dev/null +++ b/packages/openai-decisions/VERIFICATION.md @@ -0,0 +1,41 @@ +# Verification — 2026-11-05 + +Validated locally with Magpie 0.1.3039, Node 26.3.0 and Magpie's Bun 1.4.4. +The community package harness was read from magpie-community/plugins +commit `808f7a184d3a88db69691f4838deee46f64560b1`, then rechecked for +submission against `41e90a18d3affc7487460d032b0cdc5e09b20ebb`. + +## Automated checks + +- `npm test`: 18 pass, 0 fail. +- Community `bun scripts/check.mjs openai-decisions`: registry and provider checks pass. +- Community `bun test packages/openai-decisions`: 18 pass, 0 fail. +- OpenCode compatibility bridge matches the community's canonical file byte for byte. + +## Real local gateway + +The same synthetic text/JSON case exercised choice, noul and score: + +| Request model | Result | Actual model | +| --- | --- | --- | +| `typesafe/jev-latest` | HTTP 200; all three answers and distributions present | `jev-1.14.0` | +| `openai-decisions/gpt-6-luna` | HTTP 200; all three answers and distributions present | `gpt-6-luna` | +| `group/decisions` | HTTP 200; all three answers and distributions present | `jev-1.14.0`, first in the group's order | + +A temporary chat group with `classifier=openai-decisions/gpt-6-luna` and +`effort=auto` returned HTTP 200 with `OK`. Magpie's usage record separately +confirmed a real `kind=routing`, provider `openai-decisions`, HTTP 200 +classification call. Its downstream chat model was the catalog's +`openai/gpt-5.6-luna`; the temporary group was then removed. + +The optional saved-key sign-in was exercised against the real gateway. +Ordinary API-key sign-in is covered by the auth hook tests; it does not +require an existing built-in OpenAI provider. + +Live rate-limit/quota failures were not forced. Their status/body/header +handling is covered by fixtures, while cross-provider failover remains +Magpie's native responsibility. Image and box decisions are outside this +adapter's declared capabilities. Different vendors' confidence fields +retain their original calibration. + +These local checks do not represent community review or acceptance. diff --git a/packages/openai-decisions/adapter.mjs b/packages/openai-decisions/adapter.mjs new file mode 100644 index 000000000..a9a63bb26 --- /dev/null +++ b/packages/openai-decisions/adapter.mjs @@ -0,0 +1,178 @@ +export const MODEL = "gpt-6-luna"; +const SYSTEM_ONE_URL = "https://api.openai.com/v1/systemone"; +const DECISIONS_URL = "https://api.openai.com/v1/decisions"; +const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +function requireValue(condition, message) { + if (!condition) throw new Error(message); +} + +function fields(value, allowed, name) { + requireValue(record(value), `${name} must be an object`); + requireValue(Object.keys(value).every((key) => allowed.includes(key)), `${name} contains an unsupported field`); +} + +function description(value) { + return typeof value === "string" ? value : JSON.stringify(value); +} + +function questionInstructions(question) { + if (question.type === "noul" && record(question.criteria)) { + return JSON.stringify({ instructions: question.instructions ?? null, criteria: question.criteria }); + } + return question.instructions == null ? "" : description(question.instructions); +} + +function choiceEntries(question) { + if (question.choices !== undefined) { + requireValue(question.criteria === undefined, "choice accepts criteria or choices, not both"); + requireValue(Array.isArray(question.choices) && question.choices.every((value) => typeof value === "string"), "choices must be strings"); + requireValue(new Set(question.choices).size === question.choices.length, "choices must be unique"); + return question.choices.map((value) => [value, null]); + } + requireValue(record(question.criteria), "choice requires a criteria object"); + return Object.entries(question.criteria); +} + +export function toOpenAIRequest(body) { + fields(body, ["model", "state", "questions"], "request"); + requireValue(body.model === MODEL, `Only ${MODEL} is supported`); + requireValue(Object.hasOwn(body, "state") && body.state !== null, "state is required"); + requireValue(record(body.questions), "questions must be an object"); + const entries = Object.entries(body.questions); + requireValue(entries.length > 0 && entries.length <= 400, "Expected 1–400 questions"); + const questions = entries.map(([name, question]) => { + requireValue(name.length > 0, "Question IDs must not be empty"); + fields(question, ["type", "instructions", "criteria", "choices"], "question"); + requireValue(question.instructions == null || typeof question.instructions === "string" || typeof question.instructions === "object", "instructions must be text or structured JSON"); + const common = { name, type: question.type, instructions: questionInstructions(question) }; + switch (question.type) { + case "choice": { + const criteria = choiceEntries(question); + requireValue(criteria.length >= 2 && criteria.length <= 255, "choice requires 2–255 options"); + requireValue(criteria.every(([key]) => key.length > 0), "Choice names must not be empty"); + return { ...common, choices: criteria.map(([value, text]) => ({ value, ...(text != null ? { description: description(text) } : {}) })) }; + } + case "score": + requireValue(question.choices === undefined, "score does not accept choices"); + requireValue(Array.isArray(question.criteria) && question.criteria.length >= 2 && question.criteria.length <= 10, "score requires 2–10 ordered criteria"); + return { ...common, levels: question.criteria.map((value, index) => ({ label: String(index), ...(value != null ? { description: description(value) } : {}) })) }; + case "noul": + requireValue(question.choices === undefined, "noul does not accept choices"); + requireValue(question.criteria == null || record(question.criteria) && Object.keys(question.criteria).every((key) => key === "true" || key === "false"), "noul criteria accept true and false only"); + return { ...common, type: "predicate" }; + default: + throw new Error("Unsupported question type; use choice, score or noul"); + } + }); + const input = typeof body.state === "string" + ? body.state + : typeof body.state === "object" + ? [{ role: "user", content: [{ type: "input_json", data: body.state }] }] + : JSON.stringify(body.state); + return { model: MODEL, input, questions }; +} + +function probability(value) { + requireValue(typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1, "Invalid upstream probability or confidence"); + return value; +} + +function distribution(values, expected, score) { + requireValue(Array.isArray(values) && values.length === expected.length, "Incomplete upstream probability distribution"); + const seen = new Set(); + const result = values.map((item) => { + requireValue(record(item), "Invalid upstream probability entry"); + requireValue(score ? Number.isInteger(item.value) : typeof item.value === "string", "Invalid upstream probability value"); + const key = String(item.value); + requireValue(expected.includes(key) && !seen.has(key), "Unexpected or duplicate upstream probability value"); + seen.add(key); + return [key, probability(item.probability)]; + }); + requireValue(Math.abs(result.reduce((sum, [, value]) => sum + value, 0) - 1) <= 0.02, "Upstream probabilities do not sum to one"); + return Object.fromEntries(result); +} + +export function fromOpenAIResponse(upstream, source) { + const request = toOpenAIRequest(source); + requireValue(record(upstream) && typeof upstream.model === "string", "Invalid upstream decision response"); + requireValue(Array.isArray(upstream.answers) && upstream.answers.length === request.questions.length, "Incomplete upstream answers"); + const byName = new Map(request.questions.map((question) => [question.name, question])); + const answers = new Map(); + for (const answer of upstream.answers) { + requireValue(record(answer) && byName.has(answer.name) && !answers.has(answer.name), "Unexpected or duplicate upstream answer"); + const question = byName.get(answer.name); + requireValue(answer.type === question.type, "Wrong upstream answer type"); + let result; + if (answer.type === "predicate") { + result = { type: "noul", noul: probability(answer.probability) }; + } else if (answer.type === "choice") { + const keys = question.choices.map((choice) => choice.value); + requireValue(keys.includes(answer.choice), "Upstream chose an unknown option"); + result = { type: "choice", choice: answer.choice, probabilities: distribution(answer.probabilities, keys, false), confidence: probability(answer.confidence) }; + } else { + requireValue(typeof answer.score === "number" && Number.isFinite(answer.score) && answer.score >= 0 && answer.score <= question.levels.length - 1, "Invalid upstream score"); + result = { + type: "score", score: answer.score, + legend: Object.fromEntries(question.levels.map((level, index) => [String(index), level.description ?? level.label])), + probabilities: distribution(answer.probabilities, question.levels.map((_, index) => String(index)), true), + confidence: probability(answer.confidence), + }; + } + answers.set(answer.name, result); + } + // 按请求顺序组织答案;供应商可以按不同顺序返回命名问题。 + return { ...upstream, answers: Object.fromEntries(request.questions.map(({ name }) => [name, answers.get(name)])) }; +} + +function responseHeaders(response) { + const headers = new Headers({ "content-type": response.headers.get("content-type") || "application/json" }); + for (const [key, value] of response.headers) { + if (key === "retry-after" || key === "x-request-id" || key.startsWith("x-ratelimit-")) headers.set(key, value); + } + return headers; +} + +function refused(status, message, headers) { + return Response.json({ error: { type: status === 400 ? "invalid_request_error" : "api_error", message } }, { status, headers }); +} + +export function createDecisionFetch({ getKey, fetchImpl = globalThis.fetch }) { + return async (input, init) => { + let request, source, payload; + try { + request = new Request(input, init); + requireValue(request.url === SYSTEM_ONE_URL && request.method === "POST", "Only POST /v1/systemone is supported"); + source = await request.json(); + payload = toOpenAIRequest(source); + } catch (error) { + if (request?.signal.aborted) throw error; + return refused(400, error instanceof SyntaxError ? "Invalid JSON request" : error.message); + } + request.signal.throwIfAborted(); + let key; + try { + key = await getKey(); + requireValue(typeof key === "string" && key.length > 0 && !/[\r\n]/.test(key), "Missing OpenAI API key"); + } catch { + return refused(503, "OpenAI credentials unavailable; check this provider's sign-in or its saved OpenAI key"); + } + const headers = new Headers({ "content-type": "application/json", accept: "application/json", authorization: `Bearer ${key}` }); + let upstream; + try { + upstream = await fetchImpl(DECISIONS_URL, { method: "POST", headers, body: JSON.stringify(payload), signal: request.signal, redirect: "error" }); + } catch (error) { + if (request.signal.aborted) throw error; + return refused(502, "Could not reach OpenAI Decisions"); + } + const keptHeaders = responseHeaders(upstream); + if (!upstream.ok) return new Response(upstream.body, { status: upstream.status, headers: keptHeaders }); + try { + const result = fromOpenAIResponse(await upstream.json(), source); + return Response.json(result, { status: upstream.status, headers: keptHeaders }); + } catch (error) { + if (request.signal.aborted) throw error; + return refused(502, "OpenAI returned an invalid or incomplete decision answer", keptHeaders); + } + }; +} diff --git a/packages/openai-decisions/credentials.mjs b/packages/openai-decisions/credentials.mjs new file mode 100644 index 000000000..dc793691c --- /dev/null +++ b/packages/openai-decisions/credentials.mjs @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const SAVED_OPENAI = "magpie-provider:openai"; + +function providersFile() { + const root = process.env.XDG_CONFIG_HOME || (process.platform === "win32" + ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") + : join(homedir(), ".config")); + return join(root, "magpie", "providers.json"); +} + +export function readOpenAIKey(path = providersFile()) { + let data; + try { + data = JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error("Could not read Magpie's saved OpenAI provider"); + } + const provider = data.providers?.find((row) => row.id === "openai"); + const endpoints = [provider?.chat, provider?.responses].filter(Boolean); + if (!provider || provider.off || provider.preset !== "openai" || !endpoints.length || endpoints.some((url) => url !== "https://api.openai.com/v1")) { + throw new Error("An enabled official OpenAI provider with ID openai is required for saved-key sign-in"); + } + if (typeof provider.key !== "string" || !provider.key.trim() || /[\r\n]/.test(provider.key)) throw new Error("The saved OpenAI provider has no usable primary API key"); + return provider.key; +} + +export function keyFromAuth(auth) { + if (auth?.type !== "api" || typeof auth.key !== "string" || !auth.key) throw new Error("Sign in to OpenAI Decisions first"); + return auth.key === SAVED_OPENAI ? readOpenAIKey() : auth.key; +} diff --git a/packages/openai-decisions/index.mjs b/packages/openai-decisions/index.mjs new file mode 100644 index 000000000..c6bee61b2 --- /dev/null +++ b/packages/openai-decisions/index.mjs @@ -0,0 +1,48 @@ +import { createDecisionFetch, MODEL } from "./adapter.mjs"; +import { keyFromAuth, readOpenAIKey, SAVED_OPENAI } from "./credentials.mjs"; + +const ID = "openai-decisions"; +const BASE = "https://api.openai.com/v1"; +const model = { + id: MODEL, + name: "GPT-6 Luna · Decisions", + decides: true, + tool_call: false, + reasoning: false, + limit: { context: 0, output: 0 }, + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 0.1, output: 0 }, +}; + +// 入口只导出插件函数;Magpie 会把导出的函数逐一作为插件加载。 +export const OpenAIDecisionsPlugin = async () => ({ + async config(config) { + config.provider ??= {}; + config.provider[ID] ??= { name: "OpenAI Decisions", npm: "@ai-sdk/openai-compatible", api: BASE, decide: true, models: { [MODEL]: model } }; + }, + auth: { + provider: ID, + methods: [ + { type: "api", label: "OpenAI API key", placeholder: "sk-…" }, + { + type: "api", keyless: true, + label: "Use the OpenAI key already saved in Magpie", + async authorize() { + readOpenAIKey(); + return { type: "success", key: SAVED_OPENAI }; + }, + }, + ], + async loader(getAuth) { + const auth = await getAuth(); + if (auth?.type !== "api" || !auth.key) return {}; + return { baseURL: BASE, apiKey: keyFromAuth(auth), fetch: createDecisionFetch({ getKey: async () => keyFromAuth(await getAuth()) }) }; + }, + }, + provider: { + id: ID, + async models() { + return { [MODEL]: model }; + }, + }, +}); diff --git a/packages/openai-decisions/opencode.mjs b/packages/openai-decisions/opencode.mjs new file mode 100644 index 000000000..2ac682b85 --- /dev/null +++ b/packages/openai-decisions/opencode.mjs @@ -0,0 +1 @@ +export { OpenAIDecisionsPlugin } from "./index.mjs"; diff --git a/packages/openai-decisions/opencode2.mjs b/packages/openai-decisions/opencode2.mjs new file mode 100644 index 000000000..7aa300bcf --- /dev/null +++ b/packages/openai-decisions/opencode2.mjs @@ -0,0 +1,594 @@ +// The OpenCode 2.x face of a package whose plugins are written for +// OpenCode 1.x (and magpie's plugin host). +// +// OpenCode 2.x loads a package's `exports["."]` and wants its default to be +// {id, setup(ctx)}; it has no fallback for a 1.x plugin's named functions +// (plugins#657). This file is that default. It runs each 1.x plugin the +// package's opencode.mjs exports and hands 2.x what the plugin's hooks say: +// +// auth.methods → the integration's sign-in methods +// config() → the provider, its package, base URL and models +// provider.models → the signed-in account's own model list +// auth.loader → each request's base URL, key, headers and fetch +// +// What 2.x has no place for is left out: chat.headers and chat.params +// (2.x's sdk hook doesn't carry the session), magpie's usage and checkin, +// tools and events. +// +// scripts/opencode2.mjs is the one copy that is edited. Every package that +// has it carries the same bytes as packages//opencode2.mjs, and +// scripts/check.mjs fails when a copy differs. + +import { readFileSync } from "node:fs" +import * as v1 from "./opencode.mjs" + +const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) + +// The key the SDK is made with: the account's own goes on each request in +// its place (see wrapFetch), so a key read when the SDK was made can't go +// out after the account changed. +const PLACEHOLDER = "opencode2-v1-key" + +// The SDK package a 1.x npm name runs as on 2.x. A version keeps 2.x from +// swapping @ai-sdk/* for its native providers, which never call the sdk +// hook below; openai-compatible's is the one 2.x ships, the others are AI +// SDK v6's dist-tag, the line 2.x's bridge runs. +export function packageOf(npm) { + const name = npm || "@ai-sdk/openai-compatible" + if (name === "@ai-sdk/openai-compatible") return "aisdk:@ai-sdk/openai-compatible@2.0.41" + if (/^@ai-sdk\/[^/@]+$/.test(name)) return `aisdk:${name}@ai-v6` + return `aisdk:${name}` +} + +// The plugins a 1.x module exports, as magpie's host and OpenCode 1.x read +// them: a default {server} is the one plugin, else every function export +// and every {server} object. +export function pluginsOf(mod) { + const d = mod?.default + if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) + return typeof d.server === "function" ? [d.server] : [] + const out = [] + for (const value of Object.values(mod ?? {})) { + const fn = typeof value === "function" ? value : value && typeof value === "object" && typeof value.server === "function" ? value.server : null + if (fn && !out.includes(fn)) out.push(fn) + } + return out +} + +const int = (n, fallback = 0) => { + const v = Math.trunc(Number(n)) + return Number.isFinite(v) && v >= 0 ? v : fallback +} +const money = (n) => (Number.isFinite(Number(n)) && Number(n) >= 0 ? Number(n) : 0) +const record = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {}) +const json = (v) => { + try { + return JSON.parse(JSON.stringify(v ?? null)) + } catch { + return null + } +} +const strings = (v) => Object.fromEntries(Object.entries(record(v)).filter(([, s]) => typeof s === "string")) +const nonEmpty = (o) => (Object.keys(o).length ? o : undefined) + +// connKey names a connection the way 2.x does. +const connKey = (c) => (!c ? "" : c.type === "credential" ? `credential:${c.id}` : `env:${c.name}`) + +// ---- sign-in ------------------------------------------------------------ + +// fields is a 1.x method's prompts as a 2.x form. +export function fieldsOf(prompts) { + const out = [] + for (const p of prompts ?? []) { + if (!p?.key) continue + const f = { key: String(p.key), type: "string", title: String(p.message ?? p.key) } + if (p.placeholder) f.placeholder = String(p.placeholder) + if (p.type === "select" && Array.isArray(p.options)) + f.options = p.options.map((o) => ({ value: String(o.value), label: String(o.label ?? o.value), ...(o.hint ? { description: String(o.hint) } : {}) })) + // a prompt shown only when another answer says so can't be required + if (!p.condition) f.required = true + out.push(f) + } + return out.length ? out : undefined +} + +// v1Saved is what a 1.x sign-in's success is saved as, the way 1.x writes +// auth.json: the result without its type and provider. +function v1Saved(r) { + const { type: _t, provider: _p, ...rest } = r + if ("key" in r) return { type: "api", ...rest } + return { type: "oauth", ...rest } +} + +// credentialOf is a 1.x sign-in's success as a 2.x credential: OAuth for +// both kinds, the 1.x auth kept whole in its metadata. No refresh is +// registered, so 2.x hands the credential over as it is and the plugin's +// own loader renews it, as it does on 1.x. +export function credentialOf(methodID, r) { + if (!r || r.type !== "success") throw new Error(r?.error || r?.message || "the sign-in failed") + const saved = json(v1Saved(r)) + if (saved.type === "api") + return { type: "oauth", methodID, access: String(saved.key ?? ""), refresh: "", expires: 0, metadata: { v1: saved } } + return { + type: "oauth", + methodID, + access: String(saved.access ?? ""), + refresh: String(saved.refresh ?? ""), + expires: int(saved.expires), + metadata: { v1: saved }, + } +} + +// authOfValue is the 1.x auth a 2.x credential stands for. +export function authOfValue(value) { + if (!value) return null + if (value.type === "oauth") { + const v1 = record(value.metadata).v1 + if (v1 && typeof v1 === "object") return v1 + return { type: "oauth", access: value.access, refresh: value.refresh, expires: value.expires } + } + if (value.type === "key") { + const meta = value.metadata && Object.keys(value.metadata).length ? { metadata: value.metadata } : {} + return { type: "api", key: value.key, ...meta } + } + return null +} + +const fingerprint = (a) => JSON.stringify([a?.type, a?.access ?? a?.key ?? "", a?.refresh ?? ""]) + +// ---- models ------------------------------------------------------------- + +const MODALITIES = ["text", "audio", "image", "video", "pdf"] + +// internalOf is a config model as 1.x hands it to provider.models: what +// fromModelsDevModel makes of it, with the config's own variants. +export function internalOf(providerID, conf, key, m) { + const mods = record(m.modalities) + const has = (list, k) => Array.isArray(list) && list.includes(k) + const cost = record(m.cost) + return { + ...m, + id: key, + providerID, + name: m.name ?? key, + api: { id: m.id ?? key, url: m.provider?.api ?? conf.api ?? conf.options?.baseURL ?? "", npm: m.provider?.npm ?? conf.npm ?? "@ai-sdk/openai-compatible" }, + status: m.status ?? "active", + headers: m.headers ?? {}, + options: m.options ?? {}, + cost: { input: money(cost.input), output: money(cost.output), cache: { read: money(cost.cache_read), write: money(cost.cache_write) } }, + limit: { context: int(m.limit?.context), input: m.limit?.input, output: int(m.limit?.output) }, + capabilities: { + temperature: m.temperature ?? false, + reasoning: m.reasoning ?? false, + attachment: m.attachment ?? false, + toolcall: m.tool_call ?? true, + input: Object.fromEntries(MODALITIES.map((k) => [k, mods.input ? has(mods.input, k) : k === "text"])), + output: Object.fromEntries(MODALITIES.map((k) => [k, mods.output ? has(mods.output, k) : k === "text"])), + interleaved: m.interleaved ?? false, + }, + release_date: m.release_date ?? "", + variants: record(m.variants), + } +} + +// modelOf is a 1.x model, in config or internal form, as a 2.x Model.Info. +export function modelOf(providerID, provider, key, m) { + const caps = record(m.capabilities) + const listFrom = (obj, list) => { + if (obj && typeof obj === "object") return MODALITIES.filter((k) => obj[k]) + if (Array.isArray(list)) return list.map(String) + return ["text"] + } + const input = listFrom(caps.input, m.modalities?.input) + const output = listFrom(caps.output, m.modalities?.output) + const c = record(m.cost) + const cache = record(c.cache) + const npm = m.api?.npm ?? m.provider?.npm + const url = m.api?.url ?? m.provider?.api + const settings = { ...record(json(m.options)) } + if (url && url !== provider.baseURL) settings.baseURL = url + const variants = Object.entries(record(m.variants)) + .filter(([, v]) => !record(v).disabled) + .map(([id, v]) => { + const { disabled: _d, ...rest } = record(json(v)) + return nonEmpty(rest) ? { id, settings: rest } : { id } + }) + const status = ["alpha", "beta", "deprecated", "active"].includes(m.status) ? m.status : "active" + const out = { + id: key, + modelID: String(m.api?.id ?? m.id ?? key), + providerID, + name: String(m.name ?? key), + capabilities: { + tools: caps.toolcall ?? m.tool_call ?? true, + input: input.length ? input : ["text"], + output: output.length ? output : ["text"], + ...(typeof (caps.temperature ?? m.temperature) === "boolean" ? { temperature: caps.temperature ?? m.temperature } : {}), + }, + variants, + time: { released: Date.parse(m.release_date ?? "") || 0 }, + cost: [{ input: money(c.input), output: money(c.output), cache: { read: money(cache.read ?? c.cache_read), write: money(cache.write ?? c.cache_write) } }], + status, + enabled: true, + limit: { context: int(m.limit?.context, 128000) || 128000, output: int(m.limit?.output, 8192) || 8192 }, + } + if (m.limit?.input) out.limit.input = int(m.limit.input) + if (npm && npm !== provider.npm) out.package = packageOf(npm) + if (nonEmpty(settings)) out.settings = settings + const headers = strings(m.headers) + if (nonEmpty(headers)) out.headers = headers + return out +} + +// ---- requests ----------------------------------------------------------- + +const urlOf = (input) => (typeof input === "string" ? input : input instanceof URL ? input.href : input?.url ?? String(input)) + +// wrapFetch sends a request the SDK made from the provider's settings the +// way 1.x would have sent it with the loader's options: the loader's base +// URL in place of the configured one, the account's key in place of the +// placeholder, the loader's headers added, through the loader's fetch. +export function wrapFetch(next, base, options) { + return async (input, init = {}) => { + const o = await options() + let url = urlOf(input) + const from = String(base ?? "").replace(/\/+$/, "") + const to = o.baseURL ? String(o.baseURL).replace(/\/+$/, "") : "" + if (from && to && from !== to && url.startsWith(from)) url = to + url.slice(from.length) + const req = input instanceof Request ? input : null + const headers = new Headers(init.headers ?? req?.headers ?? undefined) + for (const [k, v] of [...headers.entries()]) { + if (!v.includes(PLACEHOLDER)) continue + if (o.key) headers.set(k, v.replaceAll(PLACEHOLDER, o.key)) + else headers.delete(k) + } + for (const [k, v] of Object.entries(strings(o.headers))) headers.set(k, v) + const send = typeof o.fetch === "function" ? o.fetch : next ?? fetch + if (req && init.body === undefined) { + const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.text() + return send(url, { method: req.method, headers, body, signal: init.signal ?? req.signal }) + } + return send(url, { ...init, headers }) + } +} + +// ---- one plugin --------------------------------------------------------- + +async function setupPlugin(ctx, fn, live) { + const directory = ctx?.location?.directory ?? process.cwd() + // the active account each provider was last read for, and the renewed + // auth a plugin saved for it (2.x has no way for a plugin to write a + // credential back, so it is kept in the plugin's storage, under the + // credential it renews and the token it was renewed from) + const current = new Map() + const saved = new Map() + const signingIn = new Map() + const storeKey = (id, key) => `v1auth/${id}/${key}` + + const client = { + auth: { + async set(arg) { + const id = arg?.path?.id + const body = json(arg?.body) + const cur = id && current.get(id) + // a sign-in's own saves belong to the credential it is about to + // make, not the one that was active when it started + if (!cur || !body || signingIn.get(id) > 0) return { data: true } + const entry = { fp: cur.fp, auth: body } + saved.set(storeKey(id, cur.key), entry) + await Promise.resolve() + .then(() => ctx.storage?.set?.(storeKey(id, cur.key), entry)) + .catch(() => {}) + return { data: true } + }, + async get() { + return { data: undefined } + }, + }, + app: { log: async () => ({ data: true }) }, + } + + const hooks = (await fn({ client, directory, worktree: directory, project: {}, serverUrl: undefined, $: undefined }, {})) ?? {} + const cfg = { provider: {} } + try { + await hooks.config?.(cfg) + } catch {} + const auth = hooks.auth + const authID = auth?.provider ? String(auth.provider) : undefined + const providers = record(cfg.provider) + + // getAuth reads the active account: the credential 2.x holds, or what the + // plugin last saved renewing it. + const getAuth = async (id) => { + const connection = await ctx.integration.connection.active(id).catch(() => undefined) + if (!connection) { + current.delete(id) + return undefined + } + const value = await ctx.integration.connection.resolve(connection).catch(() => undefined) + const base = authOfValue(value) + if (!base) { + current.delete(id) + return undefined + } + const key = connKey(connection) + const fp = fingerprint(base) + // ours: a credential one of this plugin's own sign-ins made + const ours = value?.type === "oauth" && !!record(value.metadata).v1 + current.set(id, { key, fp, ours }) + const sk = storeKey(id, key) + let entry = saved.get(sk) + if (!entry) { + entry = await Promise.resolve() + .then(() => ctx.storage?.get?.(sk)) + .catch(() => undefined) + if (entry) saved.set(sk, entry) + } + return entry && entry.fp === fp && entry.auth ? entry.auth : base + } + + // the sign-in methods + if (authID && Array.isArray(auth.methods) && auth.methods.length) { + const label = (c) => { + const a = authOfValue(c) + const name = a?.name ?? a?.email ?? a?.metadata?.name ?? a?.metadata?.email ?? a?.account + return typeof name === "string" && name ? name : undefined + } + const begin = () => signingIn.set(authID, (signingIn.get(authID) ?? 0) + 1) + const end = () => signingIn.set(authID, Math.max(0, (signingIn.get(authID) ?? 1) - 1)) + const title = record(providers[authID]).name + // 2.x runs a transform again on every reload, from nothing + await ctx.integration.transform((editor) => { + let keyed = false + // the integration 2.x makes for an id it doesn't know is named by the + // id; the provider's own name reads better in the sign-in list + if (typeof title === "string" && title) + try { + editor.update?.(authID, (i) => { + if (i && (!i.name || i.name === authID)) i.name = title + }) + } catch {} + auth.methods.forEach((m, i) => { + if (!m) return + const methodID = `v1-${i}` + const form = fieldsOf(m.prompts) + if (m.type === "api" && typeof m.authorize !== "function") { + if (keyed) return + keyed = true + editor.method.update({ integrationID: authID, method: { type: "key", label: String(m.label ?? "API key"), ...(form ? { form } : {}) } }) + return + } + if (typeof m.authorize !== "function") return + editor.method.update({ + integrationID: authID, + method: { id: methodID, type: "oauth", label: String(m.label ?? authID), ...(form ? { form } : {}) }, + async authorize(answer) { + begin() + let ended = false + const done = () => { + if (!ended) end() + ended = true + } + try { + const inputs = Object.fromEntries(Object.entries(record(answer)).map(([k, v]) => [k, String(v)])) + const r = await m.authorize(inputs) + if (m.type === "api") { + done() + return { url: "", instructions: "", mode: "auto", callback: Promise.resolve().then(() => credentialOf(methodID, r)) } + } + if (r?.method === "code") + return { + url: String(r.url ?? ""), + instructions: String(r.instructions ?? ""), + mode: "code", + callback: async (code) => { + try { + return credentialOf(methodID, await r.callback(code)) + } finally { + done() + } + }, + } + const callback = Promise.resolve() + .then(() => r.callback()) + .then((res) => credentialOf(methodID, res)) + .finally(done) + callback.catch(() => {}) + return { url: String(r?.url ?? ""), instructions: String(r?.instructions ?? ""), mode: "auto", callback } + } catch (e) { + done() + throw e + } + }, + label, + }) + }) + }) + } + + // the loader's options for the account signed in now, read again when + // the account changes + let loaded = null + const loaderOptions = async (id) => { + if (id !== authID || typeof auth?.loader !== "function") { + const a = authID === id ? await getAuth(id) : undefined + return { key: a?.type === "api" ? a.key : undefined } + } + const a = await getAuth(id) + if (!a) return {} + const fp = current.get(id)?.key + "\0" + fingerprint(a) + if (loaded?.fp !== fp) { + const p = Promise.resolve() + .then(() => auth.loader(() => getAuth(id), providerOf(id))) + .then((o) => record(o)) + loaded = { fp, p } + p.catch(() => { + if (loaded?.fp === fp) loaded = null + }) + } + const o = await loaded.p + const key = typeof o.apiKey === "string" && o.apiKey ? o.apiKey : a.type === "api" ? a.key : undefined + return { baseURL: o.baseURL, key, headers: o.headers, fetch: o.fetch } + } + + // the providers, with the list the account's own read last gave each + const source = new Map() + // providerOf is a provider as 1.x hands it to provider.models and + // auth.loader + const providerOf = (id) => { + const conf = record(providers[id]) + const models = Object.fromEntries(Object.entries(record(conf.models)).map(([k, m]) => [k, internalOf(id, conf, k, record(m))])) + return { id, name: conf.name ?? id, source: "config", env: [], options: record(conf.options), models } + } + const listed = new Set() + for (const [id, conf0] of Object.entries(providers)) { + const conf = record(conf0) + // a provider entry that only names the sign-in (no package, no models) + // is not a provider of models + if (!conf.npm && !Object.keys(record(conf.models)).length) continue + const options = record(conf.options) + const { apiKey: _k, baseURL: _b, fetch: _f, headers: optHeaders, ...rest } = options + // with no base URL the config leaves it to the loader: a name that + // can't resolve stands in for it, and each request swaps in the + // loader's (wrapFetch) + const baseURL = options.baseURL ?? conf.api ?? `https://${id.replace(/[^a-z0-9-]/gi, "-")}.opencode2-v1.invalid` + const npm = conf.npm ?? "@ai-sdk/openai-compatible" + const info = { + id, + ...(id === authID ? { integrationID: id } : {}), + name: String(conf.name ?? id), + activation: "auto", + package: packageOf(npm), + settings: { ...record(json(rest)), ...(baseURL ? { baseURL: String(baseURL) } : {}) }, + } + const headers = { ...strings(conf.headers), ...strings(optHeaders) } + if (nonEmpty(headers)) info.headers = headers + const prov = { baseURL, npm } + const fromConfig = () => + Object.entries(record(conf.models)).map(([key, m]) => modelOf(id, prov, key, internalOf(id, conf, key, record(m)))) + listed.add(id) + await ctx.provider.transform((editor) => { + const s = source.get(id) + editor.add({ info, models: s?.models ?? fromConfig(), sourceConnection: s?.connection }) + }) + source.set(id, { conf, prov, models: null, connection: undefined }) + } + + // each request: the placeholder key, and the fetch that swaps in the + // account's. A provider the package doesn't list (one 2.x has built in + // that the plugin only signs in to) is left to 2.x unless the plugin's + // loader serves it and one of the plugin's own sign-ins is active. + const own = authID && !listed.has(authID) && typeof auth?.loader === "function" ? [authID] : [] + for (const id of new Set([...listed, ...own])) { + const base = source.get(id)?.prov.baseURL + await ctx.aisdk.hook( + "sdk", + async (evt) => { + const opts = evt.options + if (!opts || opts.__opencode2) return + if (!listed.has(id)) { + await getAuth(id) + if (!current.get(id)?.ours) return + } + opts.__opencode2 = true + opts.apiKey = PLACEHOLDER + opts.fetch = wrapFetch(opts.fetch, opts.baseURL ?? base, () => loaderOptions(id)) + }, + { providerID: id }, + ) + } + + // refresh reads the account's own list after a sign-in or a switch + const listID = hooks.provider?.id ? String(hooks.provider.id) : undefined + let refreshing = null + let again = false + // the account the list was last read for: a credential.updated that + // leaves it as it was reads nothing again + let readFor = null + const stateOf = async () => { + const c = await ctx.integration.connection.active(authID).catch(() => undefined) + const a = c ? await getAuth(authID) : undefined + return connKey(c) + "\0" + (a ? fingerprint(a) : "") + } + const refresh = () => { + if (refreshing) { + again = true + return refreshing + } + refreshing = (async () => { + try { + if (!authID || !source.has(authID)) return + const s = source.get(authID) + const connection = await ctx.integration.connection.active(authID).catch(() => undefined) + const a = connection ? await getAuth(authID) : undefined + readFor = connKey(connection) + "\0" + (a ? fingerprint(a) : "") + if (!a || listID !== authID || typeof hooks.provider?.models !== "function") { + if (s.connection || s.models) { + s.connection = undefined + s.models = null + await ctx.provider.reload() + } + return + } + const provider = providerOf(authID) + let got + try { + got = await hooks.provider.models(provider, { auth: a }) + } catch { + got = null + } + if (connKey(await ctx.integration.connection.active(authID).catch(() => undefined)) !== connKey(connection)) { + again = true + return + } + s.connection = connection + s.models = got && typeof got === "object" ? Object.entries(got).map(([k, m]) => modelOf(authID, s.prov, k, record(m))) : null + await ctx.provider.reload() + await ctx.model?.reload?.() + } finally { + refreshing = null + if (again && live.on) { + again = false + void refresh() + } + } + })() + return refreshing + } + + if (authID && ctx.event?.subscribe) + void (async () => { + try { + // a switch names the integration; an update (a sign-in, a removal) + // names nothing, so it reads again only when this account changed + const types = ["credential.switched", "credential.updated"] + for await (const event of ctx.event.subscribe({ types })) { + if (!live.on) return + if (event?.type === "credential.switched" && event?.data?.integrationID === authID) { + loaded = null + await refresh() + } else if (event?.type === "credential.updated" && (await stateOf()) !== readFor) { + await refresh() + } + } + } catch {} + })() + void refresh() +} + +export async function setup(ctx, mod = v1) { + const live = { on: true } + for (const fn of pluginsOf(mod)) { + try { + await setupPlugin(ctx, fn, live) + } catch (e) { + // one plugin failing to start leaves the package's others working + console.error(`${pkg.name}: ${e?.message ?? e}`) + } + } + return () => { + live.on = false + } +} + +export default { id: pkg.name, setup: (ctx) => setup(ctx) } diff --git a/packages/openai-decisions/package.json b/packages/openai-decisions/package.json new file mode 100644 index 000000000..1d44d7ba3 --- /dev/null +++ b/packages/openai-decisions/package.json @@ -0,0 +1,15 @@ +{ + "name": "@magpie-community/openai-decisions", + "version": "0.1.0", + "description": "Expose OpenAI Decisions through Magpie's native System One decision API.", + "type": "module", + "main": "./opencode.mjs", + "exports": {".": "./opencode2.mjs"}, + "files": ["index.mjs", "adapter.mjs", "credentials.mjs", "opencode.mjs", "opencode2.mjs", "README.md", "LICENSE", "NOTICE.md", "CHANGELOG.md", "VERIFICATION.md"], + "scripts": {"test": "node --test test/*.test.mjs"}, + "magpie": {"name": "OpenAI Decisions", "icon": "openai"}, + "engines": {"node": ">=20"}, + "license": "MIT", + "keywords": ["magpie", "magpie-plugin", "openai", "decisions", "system-one"], + "repository": {"type": "git", "url": "https://github.com/magpie-community/plugins.git", "directory": "packages/openai-decisions"} +} diff --git a/packages/openai-decisions/test/adapter.test.mjs b/packages/openai-decisions/test/adapter.test.mjs new file mode 100644 index 000000000..31b595c15 --- /dev/null +++ b/packages/openai-decisions/test/adapter.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDecisionFetch, fromOpenAIResponse, toOpenAIRequest } from "../adapter.mjs"; + +const body = () => ({ + model: "gpt-6-luna", + state: { message: "What does HTTP 404 mean?", previous_message_kind: "question" }, + questions: { + intent: { type: "choice", instructions: "Which task?", criteria: { question: "Explain a fact", edit: null } }, + effort: { type: "score", instructions: "How hard?", criteria: ["Little work", "Some work", "Much work", "Most work"] }, + levels: { type: "noul", instructions: "Are these kinds levels of one scale?" }, + }, +}); + +const reply = () => ({ + id: "decision-test", model: "gpt-6-luna", usage: { input_tokens: 42, output_tokens: 0 }, + answers: [ + { name: "levels", type: "predicate", probability: 0.9 }, + { name: "intent", type: "choice", choice: "question", confidence: 0.8, probabilities: [{ value: "question", probability: 0.9 }, { value: "edit", probability: 0.1 }] }, + { name: "effort", type: "score", score: 0.6, confidence: 0.3, probabilities: [0.6, 0.2, 0.2, 0].map((probability, value) => ({ value, label: String(value), probability })) }, + ], +}); + +test("converts JSON evidence and all three native classifier questions", () => { + const source = body(); + const out = toOpenAIRequest(source); + assert.equal(out.model, "gpt-6-luna"); + assert.deepEqual(out.input, [{ role: "user", content: [{ type: "input_json", data: source.state }] }]); + assert.deepEqual(out.questions[0], { name: "intent", type: "choice", instructions: "Which task?", choices: [{ value: "question", description: "Explain a fact" }, { value: "edit" }] }); + assert.deepEqual(out.questions[1].levels, source.questions.effort.criteria.map((description, index) => ({ label: String(index), description }))); + assert.deepEqual(out.questions[2], { name: "levels", type: "predicate", instructions: source.questions.levels.instructions }); + assert.equal(source.questions.levels.type, "noul"); +}); + +test("accepts text evidence and Magpie's documented choices shorthand", () => { + const source = { model: "gpt-6-luna", state: "hello", questions: { intent: { type: "choice", instructions: "Kind?", choices: ["question", "edit"] } } }; + assert.equal(toOpenAIRequest(source).input, "hello"); + assert.deepEqual(toOpenAIRequest(source).questions[0].choices, [{ value: "question" }, { value: "edit" }]); +}); + +test("numeric and boolean System One state use text instead of an invalid input_json scalar", () => { + for (const state of [42, true, false]) { + assert.equal(toOpenAIRequest({ ...body(), state }).input, JSON.stringify(state)); + } +}); + +test("retains structured instructions and criteria, including predicate definitions", () => { + const source = { model: "gpt-6-luna", state: [1, 2], questions: { + kind: { type: "choice", instructions: { question: "Which?", record: { id: 1 } }, criteria: { first: { examples: [1] }, other: null } }, + yes: { type: "noul", instructions: "Does this match?", criteria: { true: { rule: "positive" }, false: "negative" } }, + rating: { type: "score", criteria: [1, { label: "higher" }, null] }, + } }; + const out = toOpenAIRequest(source); + assert.deepEqual(JSON.parse(out.questions[0].instructions), source.questions.kind.instructions); + assert.deepEqual(JSON.parse(out.questions[0].choices[0].description), { examples: [1] }); + assert.deepEqual(JSON.parse(out.questions[1].instructions), { instructions: "Does this match?", criteria: source.questions.yes.criteria }); + assert.deepEqual(out.questions[2].levels, [{ label: "0", description: "1" }, { label: "1", description: '{"label":"higher"}' }, { label: "2" }]); +}); + +test("restores named answers independent of order and preserves probabilities, confidence and usage", () => { + const out = fromOpenAIResponse(reply(), body()); + assert.equal(out.answers.intent.choice, "question"); + assert.deepEqual(out.answers.intent.probabilities, { question: 0.9, edit: 0.1 }); + assert.equal(out.answers.intent.confidence, 0.8); + assert.equal(out.answers.effort.score, 0.6); + assert.deepEqual(out.answers.effort.probabilities, { 0: 0.6, 1: 0.2, 2: 0.2, 3: 0 }); + assert.deepEqual(out.answers.levels, { type: "noul", noul: 0.9 }); + assert.deepEqual(out.usage, { input_tokens: 42, output_tokens: 0 }); + assert.equal(out.id, "decision-test"); +}); + +test("does not treat prototype-named choices or question IDs as object properties", () => { + const source = JSON.parse('{"model":"gpt-6-luna","state":"hello","questions":{"__proto__":{"type":"choice","instructions":"Kind?","criteria":{"__proto__":null,"constructor":null}}}}'); + const out = fromOpenAIResponse({ model: "gpt-6-luna", answers: [{ name: "__proto__", type: "choice", choice: "__proto__", confidence: 1, probabilities: [{ value: "__proto__", probability: 1 }, { value: "constructor", probability: 0 }] }] }, source); + assert.equal(Object.hasOwn(out.answers, "__proto__"), true); + assert.equal(Object.hasOwn(out.answers.__proto__.probabilities, "__proto__"), true); +}); + +test("rejects unsupported fields and question types before requesting the upstream", () => { + for (const source of [ + { ...body(), stream: true }, + { ...body(), model: "another-model" }, + { ...body(), questions: {} }, + { ...body(), questions: { x: { type: "text", instructions: "Explain" } } }, + { ...body(), questions: { x: { type: "choice", instructions: "Kind?", criteria: { only: null } } } }, + { ...body(), questions: { x: { ...body().questions.intent, choices: ["question", "edit"] } } }, + { ...body(), questions: { x: { ...body().questions.intent, temperature: 0 } } }, + { ...body(), questions: { x: { type: "noul", criteria: { other: "not a boolean definition" } } } }, + ]) assert.throws(() => toOpenAIRequest(source)); +}); + +test("rejects missing, duplicate, wrong-type and unrequested answers", () => { + for (const mutate of [ + (r) => r.answers.pop(), + (r) => r.answers.push(r.answers[0]), + (r) => { r.answers[0].name = "unexpected"; }, + (r) => { r.answers[0].type = "choice"; }, + ]) { + const upstream = reply(); + mutate(upstream); + assert.throws(() => fromOpenAIResponse(upstream, body())); + } +}); + +test("rejects invalid or incomplete probability distributions and invalid scores", () => { + for (const mutate of [ + (r) => { r.answers[1].probabilities[0].probability = NaN; }, + (r) => { r.answers[1].probabilities[0].probability = 1.1; }, + (r) => r.answers[1].probabilities.pop(), + (r) => { r.answers[1].probabilities[1].value = "question"; }, + (r) => { r.answers[1].probabilities[0].probability = 0.1; }, + (r) => { r.answers[1].choice = "unknown"; }, + (r) => { r.answers[1].confidence = -1; }, + (r) => { r.answers[2].score = 4; }, + (r) => { r.answers[0].probability = -0.1; }, + ]) { + const upstream = reply(); + mutate(upstream); + assert.throws(() => fromOpenAIResponse(upstream, body())); + } +}); + +test("fetch uses the fixed Decisions URL, current key and a fresh header set", async () => { + let call; + const fetcher = createDecisionFetch({ getKey: () => "fixture-upstream-key", fetchImpl: async (url, init) => { call = { url, init }; return Response.json(reply(), { headers: { "x-request-id": "req-test", "set-cookie": "not-forwarded" } }); } }); + const response = await fetcher("https://api.openai.com/v1/systemone", { method: "POST", headers: { authorization: "Bearer local-gateway-key", "x-magpie-private": "not-forwarded" }, body: JSON.stringify(body()) }); + assert.equal(response.status, 200); + assert.equal(call.url, "https://api.openai.com/v1/decisions"); + assert.equal(call.init.redirect, "error"); + assert.equal(call.init.headers.get("authorization"), "Bearer fixture-upstream-key"); + assert.equal(call.init.headers.has("x-magpie-private"), false); + assert.equal(response.headers.has("set-cookie"), false); + assert.equal(response.headers.get("x-request-id"), "req-test"); + assert.equal((await response.json()).answers.intent.choice, "question"); +}); + +test("does not send malformed requests or arbitrary endpoints upstream", async () => { + let sent = 0; + const fetcher = createDecisionFetch({ getKey: () => "fixture", fetchImpl: async () => { sent++; throw Error("should not send"); } }); + for (const [url, init] of [ + ["https://elsewhere.example/v1/systemone", { method: "POST", body: JSON.stringify(body()) }], + ["https://api.openai.com/v1/chat/completions", { method: "POST", body: JSON.stringify(body()) }], + ["https://api.openai.com/v1/systemone?upstream=elsewhere", { method: "POST", body: JSON.stringify(body()) }], + ["https://api.openai.com/v1/systemone", { method: "GET" }], + ["https://api.openai.com/v1/systemone", { method: "POST", body: "invalid-json" }], + ]) assert.equal((await fetcher(url, init)).status, 400); + assert.equal(sent, 0); +}); + +test("preserves upstream 401/429/503 status, error body and retry headers", async () => { + for (const status of [401, 429, 503]) { + const raw = JSON.stringify({ error: { message: "Upstream refused this request" } }); + const fetcher = createDecisionFetch({ getKey: () => "fixture", fetchImpl: async () => new Response(raw, { status, headers: { "retry-after": "5", "content-type": "application/json" } }) }); + const response = await fetcher("https://api.openai.com/v1/systemone", { method: "POST", body: JSON.stringify(body()) }); + assert.equal(response.status, status); + assert.equal(await response.text(), raw); + assert.equal(response.headers.get("retry-after"), "5"); + } +}); + +test("an incomplete successful upstream response becomes 502", async () => { + const fetcher = createDecisionFetch({ getKey: () => "fixture", fetchImpl: async () => Response.json({ model: "gpt-6-luna", answers: [] }) }); + assert.equal((await fetcher("https://api.openai.com/v1/systemone", { method: "POST", body: JSON.stringify(body()) })).status, 502); +}); + +test("cancellation reaches the upstream without retries or a manufactured answer", async () => { + const controller = new AbortController(); + const fetcher = createDecisionFetch({ getKey: () => "fixture", fetchImpl: async (_url, init) => { assert.equal(init.signal.aborted, false); controller.abort(); init.signal.throwIfAborted(); } }); + await assert.rejects(fetcher("https://api.openai.com/v1/systemone", { method: "POST", signal: controller.signal, body: JSON.stringify(body()) }), { name: "AbortError" }); +}); diff --git a/packages/openai-decisions/test/plugin.test.mjs b/packages/openai-decisions/test/plugin.test.mjs new file mode 100644 index 000000000..957e46747 --- /dev/null +++ b/packages/openai-decisions/test/plugin.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { keyFromAuth, readOpenAIKey } from "../credentials.mjs"; +import { OpenAIDecisionsPlugin } from "../index.mjs"; + +test("ordinary API-key sign-in works without a saved Magpie OpenAI provider", () => { + assert.equal(keyFromAuth({ type: "api", key: "fixture-direct-key" }), "fixture-direct-key"); + assert.throws(() => keyFromAuth({ type: "oauth", access: "fixture-oauth" })); + assert.throws(() => keyFromAuth({ type: "api", key: "" })); +}); + +test("reads only the configured official OpenAI provider and follows key changes", () => { + mkdirSync(resolve(".data"), { recursive: true }); + const directory = mkdtempSync(resolve(".data/test-credentials-")); + const path = join(directory, "providers.json"); + const row = { id: "openai", preset: "openai", chat: "https://api.openai.com/v1", key: "fixture-key-one" }; + try { + writeFileSync(path, JSON.stringify({ providers: [{ id: "other", key: "unrelated" }, row] }), { mode: 0o600 }); + assert.equal(readOpenAIKey(path), "fixture-key-one"); + row.key = "fixture-key-two"; + writeFileSync(path, JSON.stringify({ providers: [row] })); + assert.equal(readOpenAIKey(path), "fixture-key-two"); + for (const invalid of [{ ...row, off: true }, { ...row, key: "" }, { ...row, preset: "another-vendor" }, { ...row, chat: "https://another.example/v1" }]) { + writeFileSync(path, JSON.stringify({ providers: [invalid] })); + assert.throws(() => readOpenAIKey(path)); + } + } finally { + unlinkSync(path); + rmdirSync(directory); + } +}); + +test("registers one real decision model and an explicit local-key sign-in method", async () => { + const plugin = await OpenAIDecisionsPlugin({}); + const config = {}; + await plugin.config(config); + const provider = config.provider["openai-decisions"]; + assert.equal(provider.decide, true); + assert.equal(provider.models["gpt-6-luna"].decides, true); + assert.equal(provider.models["gpt-6-luna"].tool_call, false); + assert.equal(plugin.auth.provider, "openai-decisions"); + assert.equal(plugin.auth.methods[0].type, "api"); + assert.notEqual(plugin.auth.methods[0].keyless, true); + assert.equal(plugin.auth.methods[1].keyless, true); + assert.equal(typeof plugin.auth.methods[1].authorize, "function"); +}); + +test("the entry module exports only the plugin, as Magpie's loader expects", async () => { + const exports = await import("../index.mjs"); + assert.deepEqual(Object.keys(exports), ["OpenAIDecisionsPlugin"]); + const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))); + assert.equal(packageJson.main, "./opencode.mjs"); + const legacy = await import("../opencode.mjs"); + assert.deepEqual(Object.keys(legacy), ["OpenAIDecisionsPlugin"]); + const modern = await import("../opencode2.mjs"); + assert.equal(modern.default.id, packageJson.name); + assert.equal(typeof modern.default.setup, "function"); +}); diff --git a/registry.json b/registry.json index f46687d7a..fe1dbcff0 100644 --- a/registry.json +++ b/registry.json @@ -220,6 +220,19 @@ "summary": { "en": "Unofficial Tabbit (web.tabbit.com) provider: reuse the Tabbit Browser sign-in on this computer to serve its chat models, with the account's live catalog, cycle usage and the daily check-in. No browser automation: a turn is Tabbit's own room → run → event-stream calls. Client tool calls are not available (Tabbit's tools are its own).", "zh": "非官方 Tabbit(web.tabbit.com)插件:复用本机 Tabbit 浏览器的登录态接入它的聊天模型,含账号实时模型目录、周期用量与每日签到。不走浏览器自动化:一轮对话就是 Tabbit 自己的 建房间 → 发起 → 事件流。不支持客户端工具调用(Tabbit 的工具是它自己的)。" } }, { "package": "magpie-plugin-codar", "name": "Codar (科搭)", "icon": "https://codar.speedai.com/assets/codar-icon.png", "providers": ["codar"], "community": true, "summary": { "en": "Codar (科搭), SpeedAI's research desktop app: its 17 models (the auto router plus the DeepSeek, GLM, GPT and Claude families) over OpenAI Chat and Anthropic Messages, with the model list refreshed live from the vendor. Signs in by reading the desktop app's token file on this machine (read-only) or by pasting an access token; the daily check-in reports the claim and carries the next claim time; the card shows general and agent points and any running free-model activity.", "zh": "科搭(Codar,SpeedAI 出品的研究桌面应用):17 个模型(auto 路由 + DeepSeek、GLM、GPT、Claude 各系),走 OpenAI Chat 与 Anthropic Messages,模型列表从厂商实时刷新。登录可读取本机桌面端令牌(只读)或粘贴访问令牌;每日签到报告领取结果并带上下次可领时间;卡片显示通用与 Agent 点数及进行中的免费模型活动。" } }, - {"package": "@magpie-community/gateway-agentcompass", "name": "AgentCompass", "kind": "middleware", "community": true, "summary": {"en": "Pure intent and multi-dimensional capability routing for Magpie: 10-field System-One task profiling, 7D capability vector + 3D Model DNA matching, automatic reasoning effort scaling, and /v1/systemone decision support.", "zh": "基于 AgentCompass 核心算法的纯意图与多维能力路由插件:10 维 System-One 任务画像、7 维客观能力向量 + 3 维模型基因(Model DNA)匹配、自动思考档位(Effort)缩放与会话亲和度路由。"}, "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSI+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjgiIHN0cm9rZT0iIzNCODJGNiIgc3Ryb2tlLXdpZHRoPSI0IiBmaWxsPSIjMEYxNzJBIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjIiIHN0cm9rZT0iIzYwQTVGQSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1kYXNoYXJyYXk9IjMgMyIvPjxwb2x5Z29uIHBvaW50cz0iMzIsMTIgMzgsMzIgMzIsMjggMjYsMzIiIGZpbGw9IiNFRjQ0NDQiLz48cG9seWdvbiBwb2ludHM9IjMyLDUyIDM4LDMyIDMyLDM2IDI2LDMyIiBmaWxsPSIjRTJFOEYwIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMy41IiBmaWxsPSIjRjhGQUZDIi8+PC9zdmc+"} + {"package": "@magpie-community/gateway-agentcompass", "name": "AgentCompass", "kind": "middleware", "community": true, "summary": {"en": "Pure intent and multi-dimensional capability routing for Magpie: 10-field System-One task profiling, 7D capability vector + 3D Model DNA matching, automatic reasoning effort scaling, and /v1/systemone decision support.", "zh": "基于 AgentCompass 核心算法的纯意图与多维能力路由插件:10 维 System-One 任务画像、7 维客观能力向量 + 3 维模型基因(Model DNA)匹配、自动思考档位(Effort)缩放与会话亲和度路由。"}, "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSI+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjgiIHN0cm9rZT0iIzNCODJGNiIgc3Ryb2tlLXdpZHRoPSI0IiBmaWxsPSIjMEYxNzJBIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjIiIHN0cm9rZT0iIzYwQTVGQSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1kYXNoYXJyYXk9IjMgMyIvPjxwb2x5Z29uIHBvaW50cz0iMzIsMTIgMzgsMzIgMzIsMjggMjYsMzIiIGZpbGw9IiNFRjQ0NDQiLz48cG9seWdvbiBwb2ludHM9IjMyLDUyIDM4LDMyIDMyLDM2IDI2LDMyIiBmaWxsPSIjRTJFOEYwIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMy41IiBmaWxsPSIjRjhGQUZDIi8+PC9zdmc+"}, + { + "package": "@magpie-community/openai-decisions", + "name": "OpenAI Decisions", + "providers": [ + "openai-decisions" + ], + "community": true, + "icon": "openai", + "summary": { + "en": "OpenAI's native Decisions API as System One: choice, score and boolean probabilities for Magpie's classifiers and decision groups; API keys or an existing saved OpenAI key.", + "zh": "将 OpenAI 原生 Decisions API 接入 System One,供 Magpie 分类器与决策模型组使用;保留选择、评分和布尔概率,支持 API key 或复用已保存的 OpenAI key。" + } + } ] } From ba568b4f02bac32f7fa4640a3344b5518d562260 Mon Sep 17 00:00:00 2001 From: lintonggeng <15643640+genglintong@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:41:47 +0800 Subject: [PATCH 2/2] openai-decisions: document installation, sign-in storage and classifiers Add the provider to the repository README and synchronize its package README, English/Chinese registry summaries, changelog and verification scope. Explain released/source installation, native decision-group/classifier setup, credential storage, shared OpenAI API allowance and quota/error handling. Runtime modules, dependencies and the initial unpublished 0.1.0 version are unchanged. Follow the community README's layout and loader conventions; keep the canonical OpenCode bridge untouched. Related PR: #762. Verification on this tree: - npm test in packages/openai-decisions: 18 pass, 0 fail. - Bun package tests: 18 pass, 0 fail. - bun scripts/check.mjs openai-decisions: registry and provider pass. - scripts/try.sh openai-decisions with Magpie 0.1.3039: sandbox installation and unsigned provider listing pass, one model, no vendor call. - Documentation checks: six local links and one JSON request example pass; registry descriptions and local source docs match; bridge is identical. - git diff --check and credential/private-data scan pass. No live model requests were made for this documentation follow-up. The full community suite was not run locally; upstream CI remains required. Live OpenCode decision workflows and Windows/Linux execution remain unverified. --- README.md | 10 +- packages/openai-decisions/CHANGELOG.md | 1 + packages/openai-decisions/README.md | 131 ++++++++++++++++++---- packages/openai-decisions/VERIFICATION.md | 17 +++ registry.json | 4 +- 5 files changed, 138 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 400386f46..eaad10ef2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # magpie-community plugins [OpenCode](https://opencode.ai) provider plugins for coding-plan -subscriptions, and [gateway middleware](#gateway-middleware), +subscriptions and decision APIs, and [gateway middleware](#gateway-middleware), [gateway hooks](#gateway-hooks), [routing strategies](#routing-strategies), [tools for every agent](#tools-for-every-agent), [pages](#pages) and [agents](#agents) for magpie, maintained by the community. To make one, see -[Write your own](#write-your-own). Each package signs in to one -subscription and makes its requests. The packages work in OpenCode and in +[Write your own](#write-your-own). Each provider package signs in to its +vendor service and makes its requests. The packages work in OpenCode and in [magpie](https://usemagpie.ai), which runs OpenCode's provider plugins. | Package | Signs in to | Provider id | @@ -33,6 +33,7 @@ subscription and makes its requests. The packages work in OpenCode and in | [minimax](packages/minimax) | MiniMax Code (international): account credits and M Plan, with MiniMax Code's device sign-in | `minimax-code-global` | | [mimo](packages/mimo) | Xiaomi MiMo, with a Xiaomi account | `mimo-app` | | [nous](packages/nous) | Nous Portal models, with Nous Portal's device sign-in | `nous-portal` | +| [openai-decisions](packages/openai-decisions) | OpenAI's native Decisions API as System One for magpie's classifiers and decision groups, with an API key or explicit reuse of magpie's saved official OpenAI key | `openai-decisions` | | [opendesign](packages/opendesign) | Open Design Cloud (open-design.ai), with the Open Design CLI's device sign-in | `opendesign` | | [qoder](packages/qoder) | Qoder subscription (qoder.com), with Qoder's device sign-in | `qoder` | | [qoder](packages/qoder) | Qoder CN subscription (qoder.cn), with Qoder CN's device sign-in | `qoder-cn` | @@ -47,6 +48,9 @@ subscription and makes its requests. The packages work in OpenCode and in | [zed](packages/zed) | Zed (Pro, Pro Trial, Student, Business): Anthropic, OpenAI, Google and xAI models hosted by Zed | `zed` | | [zen-free](packages/zen-free) | OpenCode Zen free models with the public credential | `opencode-zen-free` | +Decision providers are used through magpie's classifiers and `/v1/systemone`; +see their package READMEs for client compatibility. + ## Gateway middleware These packages aren't OpenCode plugins. magpie runs them in its gateway, on the requests every agent sends and the replies it gets back, whichever provider serves them. Each one is a port of something [New API](https://github.com/QuantumNous/new-api) does for its channels, or something agents commonly need. diff --git a/packages/openai-decisions/CHANGELOG.md b/packages/openai-decisions/CHANGELOG.md index 1ea3dfb31..ff04ec491 100644 --- a/packages/openai-decisions/CHANGELOG.md +++ b/packages/openai-decisions/CHANGELOG.md @@ -6,3 +6,4 @@ - Convert System One choice, score and noul questions while retaining answer probabilities and provider confidence. - Support API key sign-in and optional reuse of Magpie's saved official OpenAI provider. - Preserve upstream errors and cancellation; reject unsupported request shapes explicitly. +- Document released/source installation, sign-in storage, classifier setup, quota handling and verification limits; add the provider to the repository README. diff --git a/packages/openai-decisions/README.md b/packages/openai-decisions/README.md index 5c6bf3007..914dccb51 100644 --- a/packages/openai-decisions/README.md +++ b/packages/openai-decisions/README.md @@ -5,8 +5,8 @@ Magpie's existing **System One** endpoint. The model is `openai-decisions/gpt-6-luna`; use it as a routing group's classifier or a member of a decision-model group alongside other System One providers. -This package is proposed for `magpie-community/plugins`. -Install from a local checkout while community review and publication are pending. +**Provider id:** `openai-decisions`. **Decision model:** `gpt-6-luna`. +Verified with Magpie 0.1.3039; the minimum compatible release has not been established. ## Why this plugin @@ -24,23 +24,51 @@ describes the gap this fills. ## Install and sign in +Install the released community package through Magpie: + +```sh +magpie plugin add @magpie-community/openai-decisions +magpie plugin login openai-decisions +``` + +For a source checkout, including a package that is not published yet, run +from the root of `magpie-community/plugins` instead: + ```sh -magpie plugin add /absolute/path/to/magpie-openai-decisions +magpie plugin add ./packages/openai-decisions magpie plugin login openai-decisions ``` +In Magpie's Plugins page, install **OpenAI Decisions**, then sign in. +The community's distribution branch can supply a released package before +its first npm publication; a separate `npm install` is unnecessary. + +### Authentication and storage + Two sign-in methods: -1. **OpenAI API key**: Magpie's regular API-key sign-in and account store. +1. **OpenAI API key**: enter a key that can access the native Decisions API. + Magpie stores the sign-in in `plugin-auth.json` in its configuration + directory, with Unix permissions 0600. 2. **Use the OpenAI key already saved in Magpie**: explicitly reuse the - primary key of the enabled official provider whose ID is `openai`. - The sign-in record stores a provider reference; each request reads the - current saved key. This optional method follows `XDG_CONFIG_HOME` and - Magpie's usual config directory. - -Direct key sign-in works without a separate OpenAI provider. The package -follows the community's OpenCode 1.x/Magpie and OpenCode 2.x loader layout; -ordinary chat clients still need an explicit decision call to use it. + primary key of the enabled official provider whose ID and preset are + `openai`. Its configured Chat/Responses endpoints must be + `https://api.openai.com/v1`. The plugin sign-in stores the reference + `magpie-provider:openai`; each request reads the current primary key + from Magpie's `providers.json`. It leaves that file unchanged. + +The saved-key reader uses `XDG_CONFIG_HOME/magpie` when configured; +otherwise it uses `~/.config/magpie`, or `APPDATA/magpie` on Windows +with `~/AppData/Roaming/magpie` as its fallback. It selects the primary +key, not the provider's additional key pool. This optional keyless method +is a Magpie extension. + +Direct key sign-in works without a separate OpenAI provider. Requests are +billed to the OpenAI account owning the selected key; reusing a key shares +that account's API allowance. OpenCode's regular API-key sign-in uses +its own `auth.json`. The package provides the community's OpenCode 1.x +and 2.x entry points, whose loader shapes are tested; live OpenCode decision +workflows and Windows/Linux execution have not been verified. ## One endpoint @@ -66,15 +94,31 @@ Send a System One request to `http://127.0.0.1:3425/v1/systemone`: } ``` -With an existing TypeSafe provider, create a decision group: +### Decision groups and automatic effort + +With an existing TypeSafe provider, create a decision group. Choose a new +group name: `magpie group add` replaces a group of the same name. ```sh magpie group add Decisions models=typesafe/jev-latest,openai-decisions/gpt-6-luna routing=order ``` Then use `"model":"group/decisions"`. Magpie's group order and failover -decide which provider answers. A chat routing group can choose either the -decision provider or the decision group as its classifier. +decide which provider answers. + +For an existing chat routing group whose ID is `coding`, enable automatic +effort with this decision provider: + +```sh +magpie group set coding classifier=openai-decisions/gpt-6-luna effort=auto +``` + +Use your own chat group's ID in place of `coding`. Set +`classifier=group/decisions` to use the decision group instead. Chat +models remain in the chat group's `models`; decision models belong in +`classifier` or in a group containing only decision models. Magpie keeps +these models out of ordinary chat catalogs. Automatic effort rates the +turn's difficulty for chat models that support reasoning. ## Protocol and limits @@ -104,11 +148,20 @@ IDs are preserved. Invalid or incomplete successful answers become 502. Cancellation propagates upstream. Calls use the fixed OpenAI Decisions endpoint, a fresh header set and no redirects. -## Develop and contribute +## Troubleshooting -```sh -npm test -``` +| Result | Check | +| --- | --- | +| 400 from the adapter | Use `gpt-6-luna`, text/JSON state and the documented choice, score or noul fields and limits. | +| Upstream 401/403 | Check the selected OpenAI key and its Decisions access. | +| Upstream 429 | Read the quota unit and actual token usage, honor `Retry-After` when present, and space checks by that budget. A quota refusal does not establish that a model is unsupported. | +| 503: credentials unavailable | Sign in again; for saved-key mode, check that the official `openai` provider is enabled and has a usable primary key. | +| 502 from the adapter | The upstream could not be reached or returned an incomplete/invalid decision answer; inspect Magpie's request record and any retained request ID. | + +The adapter does not retry requests. Magpie owns routing and failover; +try one short question first when checking a constrained allowance. + +## Develop and contribute Tests cover conversion, structured questions, answer matching, invalid distributions, errors, cancellation and credential selection. Live Magpie @@ -122,11 +175,29 @@ bun scripts/check.mjs openai-decisions bun test packages/openai-decisions ``` +For the Node test runner, run `npm test` from `packages/openai-decisions`. +To check installation without using your real Magpie sign-ins, use the +community harness from the repository root: + +```sh +MAGPIE=/absolute/path/to/magpie scripts/try.sh openai-decisions +``` + +The harness keeps its own configuration under `.sandbox/openai-decisions`. +The command above lists the provider without signing in or calling OpenAI. +Keep automated tests offline and use synthetic credentials. + +The plugin is registered in [index.mjs](index.mjs); +[adapter.mjs](adapter.mjs) owns request/answer conversion and the fixed +upstream fetch; [credentials.mjs](credentials.mjs) owns saved-key lookup. Keep `opencode2.mjs` byte-identical to the community's canonical `scripts/opencode2.mjs`. See [NOTICE.md](NOTICE.md) for attribution. MIT license. Review checks for protocol changes: +- Keep the root README's provider table, this README, the registry's English/Chinese + summaries and the changelog aligned with the user-visible behavior. +- State each command's working directory and separate real-use evidence from fixtures. - Keep object/array evidence as `input_json`; serialize primitive evidence as text. - Preserve predicate definitions, ordered score criteria and vendor probabilities. - Distinguish transport success from complete, valid decision answers. @@ -142,9 +213,29 @@ References: [OpenAI Decisions](https://developers.openai.com/api/docs/guides/dec ## 中文 +安装社区发行包后登录: + +```sh +magpie plugin add @magpie-community/openai-decisions +magpie plugin login openai-decisions +``` + +未发布时,在社区仓库根目录运行 +`magpie plugin add ./packages/openai-decisions`,再执行同一登录命令。 +提供方 ID 是 `openai-decisions`,决策模型为 +`openai-decisions/gpt-6-luna`;已验证 Magpie 0.1.3039。 + 统一入口复用 Magpie `/v1/systemone`:兼容 System One 的供应商直接接入, 本插件负责转换 OpenAI Decisions。支持选择、评分、是非概率,保留结构化问题、 概率、置信度、用量、错误及取消行为,可用于决策模型组和意图路由。 登录可使用独立 API key,也可明确选择复用 Magpie 已保存的官方 OpenAI -提供方。社区代码使用公开协议与通用示例;组织内部端点和凭据由本地维护。 +提供方的主 key。前者保存在 Magpie 的 `plugin-auth.json`;后者只保存引用, +每次请求从 `providers.json` 读取当前主 key,共用该 key 所属 OpenAI 账号的 API +额度。已有聊天组可设置 `classifier=openai-decisions/gpt-6-luna effort=auto`, +也可把分类器设为决策模型组 `group/decisions`。 + +遇到 429 时按错误中的额度单位、实际 token 用量和 `Retry-After` 拉开调用间隔, +不要据此判断模型不受支持。真实 OpenCode 决策流程及 Windows/Linux 尚未验证; +完整验证范围见 [VERIFICATION.md](VERIFICATION.md)。社区代码使用公开协议与通用 +示例;组织内部端点和凭据由本地维护。 diff --git a/packages/openai-decisions/VERIFICATION.md b/packages/openai-decisions/VERIFICATION.md index fca6635bc..8624149b3 100644 --- a/packages/openai-decisions/VERIFICATION.md +++ b/packages/openai-decisions/VERIFICATION.md @@ -12,6 +12,23 @@ submission against `41e90a18d3affc7487460d032b0cdc5e09b20ebb`. - Community `bun test packages/openai-decisions`: 18 pass, 0 fail. - OpenCode compatibility bridge matches the community's canonical file byte for byte. +## Documentation and source installation + +- Root README provider table, package README, bilingual registry summary + and changelog describe the same provider and authentication methods. +- Package Markdown links and the JSON request example were checked against + the checked-in files and adapter. +- Community `scripts/try.sh openai-decisions` installed and listed the + provider under `.sandbox/openai-decisions`, with one decision model + and no sign-in. No OpenAI request or real account was used. +- This follow-up changes documentation and registry descriptions only; + the runtime, package version and dependencies are unchanged. + +The full community repository test suite was not run locally. It remains +a separate upstream CI requirement; focused package tests and sandbox +installation do not establish a full-suite pass. Live OpenCode decision +workflows and Windows/Linux execution were not tested. + ## Real local gateway The same synthetic text/JSON case exercised choice, noul and score: diff --git a/registry.json b/registry.json index fe1dbcff0..d2cf0acdc 100644 --- a/registry.json +++ b/registry.json @@ -230,8 +230,8 @@ "community": true, "icon": "openai", "summary": { - "en": "OpenAI's native Decisions API as System One: choice, score and boolean probabilities for Magpie's classifiers and decision groups; API keys or an existing saved OpenAI key.", - "zh": "将 OpenAI 原生 Decisions API 接入 System One,供 Magpie 分类器与决策模型组使用;保留选择、评分和布尔概率,支持 API key 或复用已保存的 OpenAI key。" + "en": "OpenAI's native /v1/decisions API as System One for Magpie's classifiers and decision groups, with choice, score and boolean probabilities. API key or explicit reuse of Magpie's saved official OpenAI key; calls use that key's OpenAI API allowance.", + "zh": "将 OpenAI 原生 /v1/decisions API 转为 System One,供 Magpie 分类器与决策模型组使用,保留选择、评分和布尔概率。支持 API key 或明确复用 Magpie 已保存的官方 OpenAI 主 key,使用该 key 所属账号的 API 额度。" } } ]