Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions docs/guide/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ A **Provider** knows how to fulfill that request through a particular service. A
configured instance of that Provider, with its service address, credential reference and capacity.
The Runtime Profile binds the requested capability to an Endpoint.

Hypit's official Distribution includes local Providers and the HypiHub Provider. Other services
connect through packages owned by the production or their authors. The Agent can implement a new
Hypit's official Distribution includes local Providers, the HypiHub Provider and the OrcaRouter
Provider. Other services connect through packages owned by the production or their authors. The Agent can implement a new
service through the public SDK, just as it can create a visual component for a video.
[Service partners](./service-partners.md) introduces independent partners through that same path.

Expand All @@ -30,6 +30,28 @@ For an existing installation, inspect the selected Profile and credential status
Profile supplies configuration examples; choose the services you want before connecting accounts or
preparing their dependencies. [Runs and Builds](../quickstart/run.md) shows the commands.

## OrcaRouter

[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway: one endpoint serves the
models of many vendors, with adaptive routing, failover and gateway-level guardrails. Its Provider is
`@hypit/provider-orcarouter`, and it offers the `chat` capability against
`https://api.orcarouter.ai/v1`.

The Endpoint declares one credential slot with two explicit entry points. `OrcaRouter - API` takes an
`sk-orca-…` key the user already holds; `OrcaRouter - Auth` runs an OAuth 2.0 + PKCE authorization
that returns a key belonging to the same account. Both store an ordinary API key in the Credential
Store the Runtime Profile selects, and a key obtained either way reaches the relay the same way.

The model list is read from `GET /v1/models` with the configured key, so the models offered are the
ones that account may call. Nothing is inferred from a model's name: a chat control only offers
entries whose catalogue record declares a chat-capable endpoint type, and attaching images only
offers entries whose record declares image input. When the catalogue cannot be read, the panel keeps
a small verified fallback and says that it is degraded rather than showing an empty list.

A PKCE-issued key is durable, not a refreshable token: it is reused until the user revokes it at
`https://www.orcarouter.ai/console/authorized-apps`. A rejected key asks for a new authorization
instead of refreshing.

## Add a Model

Develop a project package against `@hypit/hypit/model-kit`, `@hypit/hypit/generation` and `@hypit/hypit/author-kit`.
Expand Down
16 changes: 16 additions & 0 deletions docs/guide/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ binding states which instance to use. For example, with an explicitly configured
}
```

The Distribution also ships the OrcaRouter Provider, whose one credential slot accepts either a key
the user pastes or an authorization. Point it at the store the profile selects:

```json
"endpoints": {
"orcarouter.default": {
"use": "@hypit/provider-orcarouter",
"config": { "apiKey": { "store": "env", "key": "ORCAROUTER_API_KEY" } }
}
}
```

`hypit auth login orcarouter.default` runs the authorization when the slot is writable, and
`hypit auth status` reports whether the key on file is usable. See
[Models and Providers](./providers.md#orcarouter).

Installing a package makes it available; selecting it gives it a role in this environment. The Model
owns request meaning, and the Provider owns support, service mapping and pricing. A failed service
request does not silently select another account. Read the chosen Provider's README for its settings.
Expand Down
13 changes: 12 additions & 1 deletion docs/zh/guide/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: 选择账户、连接服务或添加模型,沿用同一套视频

**Model** 定义要生成什么:输入、支持的参数和输出类型。**Provider** 知道如何通过某个服务完成这个请求。**Endpoint** 是配置好的 Provider 实例,包含服务地址、凭据引用和容量。Runtime Profile 将所需能力绑定到 Endpoint。

官方发行包含本地 Provider 和 HypiHub Provider。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[服务合作方介绍](../../guide/service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。
官方发行包含本地 Provider、HypiHub Provider 和 OrcaRouter Provider。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[服务合作方介绍](../../guide/service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。

## 根据需求选择修改位置

Expand All @@ -20,6 +20,17 @@ description: 选择账户、连接服务或添加模型,沿用同一套视频

已有安装时,先检查所选 Profile 和凭据状态。起始 Profile 提供配置示例;连接账户或准备依赖前,先选择想使用的服务。[Run 与 Build](../quickstart/run.md) 介绍相关命令。

## OrcaRouter

[OrcaRouter](https://www.orcarouter.ai) 是 OpenAI 兼容的 AI 网关:一个端点提供多家厂商的模型,并带有自适应路由、故障转移和网关级防护。它的 Provider 是
`@hypit/provider-orcarouter`,通过 `https://api.orcarouter.ai/v1` 提供 `chat` 能力。

Endpoint 只声明一个凭据槽,但提供两个入口:`OrcaRouter - API` 填写用户已有的 `sk-orca-…` 密钥;`OrcaRouter - Auth` 运行 OAuth 2.0 + PKCE 授权,返回属于同一账户的密钥。两者都把普通 API 密钥存入 Runtime Profile 选择的 Credential Store,无论密钥来自哪个入口,到达中转服务的方式完全相同。

模型列表通过配置的密钥读取 `GET /v1/models`,因此可选模型就是该账户真正可调用的模型。能力不靠模型名推断:文本控件只提供目录中标明支持 chat 端点的条目,附加图片时只提供目录中明确声明图片输入的条目。目录读取失败时,面板保留一份小的已验证备用列表并标明其为降级状态,而不是显示空列表。

PKCE 签发的密钥是长期密钥,不是可刷新的令牌:在用户于 `https://www.orcarouter.ai/console/authorized-apps` 撤销之前一直复用。密钥被拒绝时应重新授权,而不是尝试刷新。

## 添加 Model

项目包使用 `@hypit/hypit/model-kit`、`@hypit/hypit/generation` 和 `@hypit/hypit/author-kit`。声明准确的请求端口、参数取值、输出类型和能力。作者 Surface 把 Prompt Text 与参考素材连接到请求,再将生成素材作为普通图输出公开。
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"@hypit/mimo-speech": "workspace:*",
"@hypit/minimax-h3": "workspace:*",
"@hypit/nano-banana": "workspace:*",
"@hypit/orcarouter": "workspace:*",
"@hypit/narrative": "workspace:*",
"@hypit/performance": "workspace:*",
"@hypit/performance-studio": "workspace:*",
Expand All @@ -234,6 +235,7 @@
"@hypit/protocol": "workspace:*",
"@hypit/provider-hyperframes-local": "workspace:*",
"@hypit/provider-hypihub": "workspace:*",
"@hypit/provider-orcarouter": "workspace:*",
"@hypit/provider-image-opencv-local": "workspace:*",
"@hypit/provider-media-local": "workspace:*",
"@hypit/provider-whisperx-local": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./oauth-host": "./src/oauth.ts"
},
"dependencies": {
"@hypit/build-result": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export async function runEnvironmentCommand(input: {
...(reportCredentialProgress === undefined ? {} : {
onProgress: (message) => reportCredentialProgress(` · ${message}\n`),
}),
...(io.readSecret === undefined ? {} : { readCode: (prompt) => io.readSecret!(prompt) }),
})
: args.credentialFile === undefined
? await io.readSecret?.(`${item.label}: `)
Expand Down
105 changes: 75 additions & 30 deletions packages/cli/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type OAuthAcquisitionOptions = {
readonly onProgress?: (message: string) => void;
readonly fetch?: typeof globalThis.fetch;
readonly open?: (url: string) => void;
/** Reads one pasted authorization code when the declared delivery has no redirect. */
readonly readCode?: (prompt: string) => Promise<string | undefined>;
};

function base64url(bytes: Uint8Array): string {
Expand Down Expand Up @@ -40,6 +42,7 @@ export async function acquireOAuthCredential(
// S256 is part of OAuth PKCE. It authenticates this browser exchange; it is not content identity.
const challenge = base64url(createHash("sha256").update(verifier).digest());
const state = base64url(randomBytes(24));
const outOfBand = acquisition.delivery === "out-of-band";
const server = createServer();
const callback = new Promise<string>((resolveCode, reject) => {
let settled = false;
Expand Down Expand Up @@ -86,18 +89,28 @@ export async function acquireOAuthCredential(
});
server.once("error", reject);
});
await new Promise<void>((resolveListen, rejectListen) => {
server.listen(0, "127.0.0.1", () => resolveListen());
server.once("error", rejectListen);
});
const address = server.address();
if (address === null || typeof address === "string") throw new Error("could not open a local OAuth callback");
const redirectUri = `http://127.0.0.1:${address.port}/callback`;
let redirectUri: string | undefined;
if (!outOfBand) {
await new Promise<void>((resolveListen, rejectListen) => {
server.listen(0, "127.0.0.1", () => resolveListen());
server.once("error", rejectListen);
});
const address = server.address();
if (address === null || typeof address === "string") throw new Error("could not open a local OAuth callback");
redirectUri = `http://127.0.0.1:${address.port}/callback`;
}
const authorize = new URL(acquisition.authorizationEndpoint);
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("client_id", acquisition.clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("scope", acquisition.scopes.join(" "));
// A declared parameter set replaces the RFC 6749 one; the challenge and state below are never
// delegable, because the PKCE binding and the CSRF check are the client's own.
for (const [name, value] of Object.entries(acquisition.authorizeParams ?? {})) {
authorize.searchParams.set(name, value);
}
if (acquisition.authorizeParams === undefined) {
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("client_id", acquisition.clientId);
authorize.searchParams.set("redirect_uri", redirectUri!);
authorize.searchParams.set("scope", acquisition.scopes.join(" "));
}
authorize.searchParams.set("state", state);
authorize.searchParams.set("code_challenge", challenge);
authorize.searchParams.set("code_challenge_method", "S256");
Expand All @@ -109,22 +122,38 @@ export async function acquireOAuthCredential(
} else {
options.open(authorize.toString());
}
const code = await callback;
const code = outOfBand ? await pastedCode(options) : await callback;
options.onProgress?.("Authorization returned. Exchanging token…");
const deadline = AbortSignal.timeout(acquisition.requestTimeoutMs);
const exchange = acquisition.exchange;
const exchanged = exchange === undefined
? {
encoding: "form" as const,
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri!,
client_id: acquisition.clientId,
code_verifier: verifier,
}).toString(),
}
: {
encoding: exchange.encoding,
headers: exchange.encoding === "json"
? { "content-type": "application/json" }
: { "content-type": "application/x-www-form-urlencoded" },
body: exchange.encoding === "json"
? JSON.stringify({ ...exchange.fields, code, code_verifier: verifier })
: new URLSearchParams({ ...exchange.fields, code, code_verifier: verifier }).toString(),
};
let tokenResponse: Response;
let body: string;
try {
tokenResponse = await (options.fetch ?? globalThis.fetch)(acquisition.tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: acquisition.clientId,
code_verifier: verifier,
}),
headers: exchanged.headers,
body: exchanged.body,
signal: deadline,
});
body = await tokenResponse.text();
Expand All @@ -138,26 +167,42 @@ export async function acquireOAuthCredential(
throw error;
}
if (!tokenResponse.ok) throw new Error(`OAuth token exchange failed (${tokenResponse.status}): ${body.slice(0, 200)}`);
const parsed = JSON.parse(body) as {
readonly access_token?: unknown;
readonly refresh_token?: unknown;
readonly expires_at?: unknown;
readonly expires_in?: unknown;
};
if (typeof parsed.access_token !== "string" || parsed.access_token.length === 0) {
throw new Error("OAuth token response contained no access token");
const parsed = JSON.parse(body) as Record<string, unknown>;
const credentialField = acquisition.exchange?.credentialField ?? "access_token";
const credential = parsed[credentialField];
if (typeof credential !== "string" || credential.length === 0) {
throw new Error(`OAuth token response contained no ${credentialField}`);
}
// Read the granted scope back: a narrower grant is refused here rather than assumed later.
const requiredScope = acquisition.exchange?.requiredScope;
if (requiredScope !== undefined && parsed.scope !== requiredScope) {
throw new Error(
`OAuth authorization granted scope ${JSON.stringify(parsed.scope ?? null)}, not ${JSON.stringify(requiredScope)}; `
+ "the account or workspace role does not permit the requested grant",
);
}
options.onProgress?.("Token received. Saving credential…");
const expiresAt = tokenExpiry(parsed);
if (acquisition.exchange?.credentialFormat === "opaque") return credential;
const expiresAt = tokenExpiry(parsed as { readonly expires_at?: unknown; readonly expires_in?: unknown });
return encodeOAuth2Credential({
accessToken: parsed.access_token,
accessToken: credential,
...(typeof parsed.refresh_token === "string" && parsed.refresh_token.length > 0
? { refreshToken: parsed.refresh_token }
: {}),
...(expiresAt === undefined ? {} : { expiresAt }),
});
}

/** Read the displayed code back from the person who approved it. */
async function pastedCode(options: OAuthAcquisitionOptions): Promise<string> {
if (options.readCode === undefined) {
throw new Error("this sign-in shows its authorization code; interactive input is unavailable, so use --from <file> instead");
}
const code = (await options.readCode("Authorization code: "))?.trim();
if (code === undefined || code.length === 0) throw new Error("no authorization code was entered");
return code;
}

function callbackPage(success: boolean): string {
const title = success ? "Authorization received" : "Hypit sign-in failed";
const heading = success ? "Authorization received" : "Hypit sign-in failed";
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/test/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { createConnection } from "node:net";
import test from "node:test";
Expand Down Expand Up @@ -95,3 +96,53 @@ test("OAuth token exchange uses the Endpoint-declared request timeout", async ()
/token exchange timed out after 20 ms.*no credential was stored/u,
);
});

test("out-of-band acquisition shows the code, exchanges it with S256, and never starts a listener", async () => {
const sent: { readonly url: string; readonly body: Record<string, string> }[] = [];
const opened: string[] = [];
const raw = await acquireOAuthCredential({
...acquisition,
delivery: "out-of-band",
authorizeParams: { callback_url: "oob", app_name: "Test Tool", scope: "api" },
exchange: { encoding: "json", fields: { code_challenge_method: "S256" }, credentialField: "key", credentialFormat: "opaque", requiredScope: "api" },
}, {
open: (url) => opened.push(url),
readCode: async () => "displayed-code",
fetch: async (input, init) => {
sent.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record<string, string> });
return Response.json({ key: "sk-orca-test", user_id: "1", scope: "api" });
},
});

// A pasted code yields the durable key itself, not a refreshable token envelope.
assert.equal(raw, "sk-orca-test");
const authorize = new URL(opened[0]!);
assert.equal(authorize.pathname, "/authorize");
assert.equal(authorize.searchParams.get("callback_url"), "oob");
assert.equal(authorize.searchParams.get("code_challenge_method"), "S256");
assert.equal(authorize.searchParams.get("response_type"), null);
assert.equal(sent[0]!.url, acquisition.tokenEndpoint);
assert.equal(sent[0]!.body.code, "displayed-code");
assert.equal(sent[0]!.body.code_challenge_method, "S256");
assert.equal(sent[0]!.body.grant_type, undefined);
const verifier = sent[0]!.body.code_verifier!;
assert.equal(Buffer.from(createHash("sha256").update(verifier).digest()).toString("base64url"),
authorize.searchParams.get("code_challenge"));
// The verifier is never placed on the URL a browser or its history can see.
assert.ok(!opened[0]!.includes(verifier));
});

test("out-of-band acquisition refuses a narrower granted scope", async () => {
await assert.rejects(
async () => await acquireOAuthCredential({
...acquisition,
delivery: "out-of-band",
exchange: { encoding: "json", credentialField: "key", credentialFormat: "opaque", requiredScope: "api" },
}, {
open: () => {},
readCode: async () => "displayed-code",
fetch: async () => Response.json({ key: "sk-orca-test", scope: "connector" }),
}),
/granted scope "connector", not "api"/u,
);
});
10 changes: 10 additions & 0 deletions packages/orcarouter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# `@hypit/orcarouter`

The Model package for OrcaRouter chat: a sealed request carrying the catalogue model ID, a prompt Text
and any attached image Artifacts, and the reply Text it publishes.

Its `model` attribute is passed to the selected Endpoint unchanged, so the vendor namespace the
catalogue returned is the namespace that reaches the relay. The Surface accepts at most eight
`Reference` children; attaching images is only meaningful for a catalogue entry that declares image
input, and the Endpoint refuses the request when the chosen model does not. The Model selects no
Provider and no credential — the Runtime Profile chooses the Endpoint that fulfills the capability.
21 changes: 21 additions & 0 deletions packages/orcarouter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@hypit/orcarouter",
"version": "0.0.0-dev",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"hypit": {
"activation": "./src/activation.ts"
},
"dependencies": {
"@hypit/artifact": "workspace:*",
"@hypit/component-kit": "workspace:*",
"@hypit/elaborator": "workspace:*",
"@hypit/markup": "workspace:*",
"@hypit/protocol": "workspace:*",
"@hypit/text": "workspace:*"
}
}
Loading