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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
## Layout

- `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
- `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` RPC channel
- `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` endpoints (the `/api/subscriptions-auth` Fetch route on DSH v0.1.2-alpha.1+, an RPC channel on v0.1.1-rc.2)
- `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s, multi-account token plumbing (`accounts.ts`), the pool (`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`), and `rate-limit.ts` (reset-instant parsing + retry policy)
- `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
- `src/tools/` — `x_search`, `image_generate`, and `video_generate`
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ pnpm test # 编译后跑 node --test 单测
## 目录结构

- `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` 端点(DSH v0.1.2-alpha.1 起走 `/api/subscriptions-auth` Fetch 路由,v0.1.1-rc.2 走 RPC 通道)
- `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现,多账号 token 管理(`accounts.ts`),模型池(`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`),以及 `rate-limit.ts`(限流重开时刻解析 + 重试策略)
- `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
- `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dsh-plugin-subscriptions",
"version": "0.8.0",
"version": "0.8.1",
"description": "Use ChatGPT (Codex), Claude, Grok (X Premium), and GitHub Copilot subscriptions as DeepSeek Harness LLM providers, with OAuth login from the web Settings page",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -57,10 +57,10 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2 || ^0.1.2-alpha.1",
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2 || ^0.1.2-alpha.1",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2 || ^0.1.2-alpha.1",
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-alpha.1",
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1",
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1",
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1",
"@deepseek-ai/schemastery": "^3.18.1"
},
"devDependencies": {
Expand All @@ -71,7 +71,7 @@
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-commands": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-commands": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-renderer": "0.1.2-alpha.3",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.3",
Expand Down
14 changes: 9 additions & 5 deletions pnpm-lock.yaml

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

7 changes: 6 additions & 1 deletion src/auth/claude-code-creds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ interface CredentialBlob {
const DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers'

function toSession(data: RawCreds): ClaudeSession | undefined {
if (typeof data.accessToken !== 'string' || typeof data.refreshToken !== 'string' || typeof data.expiresAt !== 'number') {
// Empty-string tokens (seen from a corrupted Keychain item left by a Claude
// Code logout) pass the typeof gate but are useless and would poison the
// auth store — a single such entry fails every provider's status read.
if (typeof data.accessToken !== 'string' || data.accessToken.length === 0
|| typeof data.refreshToken !== 'string' || data.refreshToken.length === 0
|| typeof data.expiresAt !== 'number' || !Number.isFinite(data.expiresAt)) {
return undefined
}
const scopes = Array.isArray(data.scopes) ? data.scopes.join(' ') : typeof data.scopes === 'string' ? data.scopes : DEFAULT_SCOPES
Expand Down
77 changes: 77 additions & 0 deletions src/auth/rpc-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* The `/subscriptions-auth` endpoints served as one exact `/api` Fetch route.
*
* dsh 0.1.5 broke `connection.rpc.handle` for every consumer: the channel is
* mounted through the connection plugin's own `webServer`, which that plugin
* no longer injects, so registration throws and the Settings page's POSTs
* fall through to the SPA (405). An exact Fetch route only enters the
* connection's route map, dispatched under its already-mounted `/api` prefix,
* and the registry exists on every host since dsh 0.1.2-alpha.1.
*
* The browser keeps calling through `rpc.call('/api', 'subscriptions-auth',
* { endpoint, payload })`, which posts the client-request envelope to this
* path; the codec answers with the same server-response envelope the host's
* channel bridge writes, so every caller sees the unchanged result shape.
* One route with the endpoint in the payload keeps a single registration
* instead of a path list that must track every endpoint.
*/

import type { RpcResult } from '../compat.js'

/** Channel-relative endpoint of the route below the shared `/api` channel. */
export const SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT = 'subscriptions-auth'

/** Absolute path of the exact Fetch route. */
export const SUBSCRIPTIONS_AUTH_ROUTE = `/api/${SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT}`

/** Decoded endpoint handler shared with the legacy channel; never throws. */
export type SubscriptionsAuthHandler = (
endpoint: string,
payload: unknown,
signal: AbortSignal,
) => Promise<RpcResult<unknown>>

function badRequest(message: string): RpcResult<unknown> {
return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } }
}

function respond(rpcId: string, result: RpcResult<unknown>): Response {
return Response.json({ type: 'server-response', rpcId, result })
}

/**
* Build the Fetch implementation of the `/api/subscriptions-auth` route.
* @param handler - the endpoint dispatcher the legacy channel also uses.
* @returns a Fetch handler for authenticated POSTs the host has already let through its trust fence.
*/
export function subscriptionsAuthFetch(handler: SubscriptionsAuthHandler): (request: Request) => Promise<Response> {
return async (request) => {
// Same content-type and body rejections as the host's channel bridge.
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await request.json()
} catch {
return new Response('body is not JSON', { status: 400 })
}

const message = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
if (message.type !== 'client-request' || typeof message.rpcId !== 'string' || typeof message.method !== 'string') {
return respond(typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request',
badRequest('invalid client-request message'))
}
if (message.method !== SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT) {
return respond(message.rpcId, badRequest(
`method ${JSON.stringify(message.method)} does not match endpoint "${SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT}"`))
}
const inner = message.payload
const endpoint = typeof inner === 'object' && inner !== null ? (inner as Record<string, unknown>).endpoint : undefined
if (typeof endpoint !== 'string' || endpoint.length === 0) {
return respond(message.rpcId, badRequest('payload.endpoint must be a non-empty string'))
}
return respond(message.rpcId, await handler(endpoint, (inner as Record<string, unknown>).payload, request.signal))
}
}
77 changes: 59 additions & 18 deletions src/auth/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
/**
* The `/subscriptions-auth` host RPC channel the web Settings page drives. The
* channel is registered only when a host `connection` service exists (the web
* profile); headless compositions load the plugin without it. All business
* outcomes are returned as RpcResult values; handlers never throw.
* The `/subscriptions-auth` endpoints the web Settings page drives, served as
* the `/api/subscriptions-auth` Fetch route (see rpc-fetch.ts) or, on rc.2, as
* a host RPC channel. They are registered only when a host `connection`
* service exists (the web profile); headless compositions load the plugin
* without it. All business outcomes are returned as RpcResult values;
* handlers never throw.
*/

import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
import type { ConnectionFetchMethod, ConnectionFetchRoute, ConnectionRpcHandler, HostConnectionFetch, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
import type { RpcResult } from '../compat.js'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { PROVIDER_IDS, type ProviderId } from './store.js'
import type { ProviderUsage } from '../providers/common.js'
import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js'
import { SUBSCRIPTIONS_AUTH_ROUTE, subscriptionsAuthFetch } from './rpc-fetch.js'

/** The RPC channel this plugin registers on the host connection. */
export const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth'
Expand Down Expand Up @@ -195,6 +198,22 @@ type RpcHandleCompat = (
options?: { readonly authority: 'loopback' },
) => () => Promise<void>

/**
* Exact Fetch route across the dsh lines, widening two fields the
* 0.1.2-alpha types this package builds against declare too narrowly:
*
* - `requestBody`: 0.1.3-alpha.1 added this required mode (the bridge reads it
* before touching the body); the 0.1.2-alpha runtime ignores the extra field.
* - `methods`: `ConnectionFetchMethod` is `'GET' | 'HEAD'` until 0.1.3-alpha.2
* widens it to include `'POST'`. The 0.1.2 runtime never validates the names
* (`assertFetchRoute` only checks the path, arity and duplicates) and matches
* with `route.methods.has(request.method)`, so a POST route is served there too.
*/
type FetchRouteCompat = Omit<ConnectionFetchRoute, 'methods'> & {
readonly methods: readonly (ConnectionFetchMethod | 'POST')[]
readonly requestBody: 'buffered' | 'streaming'
}

function ok(value: unknown): RpcResult<unknown> {
return { ok: true, value }
}
Expand Down Expand Up @@ -424,8 +443,15 @@ async function dispatch(
return ok({ ok: true })
}
case 'status': {
// One provider's failure (a corrupt store entry, a broken flow) must not
// blind the whole page: it degrades to an error detail on that provider
// while the others still report their real status.
const entries = await Promise.all(PROVIDER_IDS.map(
async provider => [provider, await controller.status(provider)] as const,
async provider => [provider, await controller.status(provider).catch((error: unknown) => ({
busy: false,
accounts: [],
detail: error instanceof Error ? error.message : String(error),
}) satisfies ProviderStatus)] as const,
))
return ok({ providers: Object.fromEntries(entries) })
}
Expand Down Expand Up @@ -490,7 +516,9 @@ async function dispatch(
}

/**
* Register the `/subscriptions-auth` RPC channel when a host connection exists.
* Register the `/subscriptions-auth` endpoints when a host connection exists:
* as the exact `/api/subscriptions-auth` Fetch route where the host has the
* Fetch registry (dsh 0.1.2-alpha.1+), else as the legacy RPC channel (rc.2).
* @param ctx - the plugin context (headless profiles have no `connection`).
* @param controller - the auth operations backing the endpoints.
* @param speed - the per-session speed-tier state backing the Speed toggle.
Expand All @@ -505,23 +533,36 @@ export function registerAuthRpc(
modelDefaults: ModelDefaultsController | undefined = undefined,
providerSettings: ProviderSettingsController | undefined = undefined,
): void {
const handler: ConnectionRpcHandler = async (endpoint, payload, signal) => {
try {
return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal, providerSettings)
} catch (error) {
return failure(error)
}
}
// `connection` is not in this plugin's inject list (headless compositions
// lack it), so its startup order is unconstrained: defer registration until
// the service exists instead of probing once at apply time.
ctx.inject(['connection'], (ctx) => {
const connection = ctx.get('connection') as HostConnectionHandle
// rc.2 has no Fetch registry despite the type; 0.1.5 can only serve this way
// (see rpc-fetch.ts), and the browser half picks the matching transport.
const fetchRoutes = (connection as { readonly fetch?: HostConnectionFetch }).fetch
if (fetchRoutes !== undefined) {
const route: FetchRouteCompat = {
path: SUBSCRIPTIONS_AUTH_ROUTE,
methods: ['POST'],
requestBody: 'buffered',
fetch: subscriptionsAuthFetch(handler),
}
ctx.effect(
() => fetchRoutes.register(route as ConnectionFetchRoute),
`dsh-plugin-subscriptions: ${SUBSCRIPTIONS_AUTH_ROUTE} fetch route`,
)
return
}
ctx.effect(
() => (connection.rpc.handle as RpcHandleCompat)(
SUBSCRIPTIONS_AUTH_CHANNEL,
async (endpoint, payload, signal) => {
try {
return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal, providerSettings)
} catch (error) {
return failure(error)
}
},
{ authority: 'loopback' },
),
() => (connection.rpc.handle as RpcHandleCompat)(SUBSCRIPTIONS_AUTH_CHANNEL, handler, { authority: 'loopback' }),
'dsh-plugin-subscriptions: /subscriptions-auth rpc channel',
)
})
Expand Down
Loading