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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ patches/
# local scratch and unpublished artifacts
/.tmp/
/.tmp-*
/tmp/
/AGENTS.md
/docs/benchmarks.zip
/worker/bin/
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- 限额重置的 `reset-status` / `reset-redeem` 固定使用 `claude-cli/<version> (external, cli)`。遥测 UA `claude-code/` 会被上游标成 `ineligible_reason=surface`,查询次数为 0。普通 `/api/oauth/usage` 仍用遥测 UA。
- 面板按平台显示按钮:Claude 槽始终显示「限额查询 / 限额重置」,Codex 槽始终显示「券查询 / 重置券」,不再要求 `credential_mode=oauth`。带 `user:profile` 的 setup-token 可以查询兑换;纯 inference 和 API Key 点查询仍会 400。
- 官方 `claude setup-token`(一年期、仅 `user:inference`、无 refresh)落盘为 `official-setup-token`,与面板转换的完整 Setup Token 区分。旧文件按 source/flavor/无 refresh+仅 inference 识别。`office-setup-token` 视为同一类型。


## 1.3.94 — 2026-10-03

- Claude 完整 OAuth 槽可以查询并兑换原生限额重置。面板在用量窗口和详情额度区沿用 GPT 重置券的「查询 / 使用」按钮,使用前二次确认。`POST /api/panel/vms/:id/claude-reset/query` 只读;`POST /api/panel/vms/:id/claude-reset/redeem` 必须带 `Idempotency-Key`,由服务端选择下一次可兑换的 grant。请求经槽内 worker 出站,响应和落盘都不含 grant / 组织 ID。未确认的兑换会按组织挡住后续兑换。Setup Token 和 API Key 不显示这组按钮。槽内 `kin-worker` 需要带 `reset-status` / `reset-redeem` 的新二进制,只更新 Node 时查询会返回 worker 不支持。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
### 完全隔离的虚拟机级 AI 订阅转 API 生产网关
**Next-Generation Fully Isolated VM-Level AI Subscription-to-API Gateway**

[![Release](https://img.shields.io/badge/Release-v1.3.93-blue.svg?style=for-the-badge&logo=github)](https://github.com/dofastted/vm2api/releases)
[![Release](https://img.shields.io/badge/Release-v1.3.94-blue.svg?style=for-the-badge&logo=github)](https://github.com/dofastted/vm2api/releases)
[![License](https://img.shields.io/badge/License-Noncommercial-amber.svg?style=for-the-badge)](LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-@VM2API-2CA5E0?style=for-the-badge&logo=telegram)](https://t.me/VM2API)
[![Benchmarks](https://img.shields.io/badge/Benchmarks-Clean%20Verified-00C853?style=for-the-badge&logo=shield)](docs/benchmarks/README.md)
Expand Down
Binary file modified bin/kin-worker
Binary file not shown.
4 changes: 2 additions & 2 deletions docs/OAUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ commitImportedOauth → 仅完整 OAuth 运行模式排队官方 Claude Code 初
| 入口 | 路径 | 说明 |
|------|------|------|
| sessionKey(默认 Setup Token) | `POST /api/panel/vms/import` `{ type: "setup-token", sessionKey }` | `sk-ant-sid*` 经槽位 SOCKS5 申请完整 OAuth scope(profile、inference、sessions、MCP、文件),采集 bootstrap 身份并 PATCH Grove;落盘后以 Setup Token 运行模式执行,不跑官方初装。 |
| 已有 OAuth → Setup Token | `POST /api/panel/vms/:id/oauth/to-setup-token` | 读 worker 活票,仅切换运行模式;保留 access、refresh、真实过期时间和全部实际 scope。不会凭空增加权限,也不会删 scope。 |
| 已有 OAuth → Setup Token | `POST /api/panel/vms/:id/oauth/to-setup-token` | 读 worker 活票,仅切换运行模式;保留 access、refresh、真实过期时间和全部实际 scope。不会凭空增加权限,也不会删 scope。官方一年期 token(`official-setup-token`)不能转。 |
| 官方 `claude setup-token` | 槽内 PTY / 粘贴一年期 oat | 只有 `user:inference`、无 refresh。落盘 `credential_mode=official-setup-token`,与面板转换的完整 Setup Token 区分。 |
| 授权链接 | `POST /api/panel/vms/:id/oauth/generate-auth-url` | CAI、Claude Code、Setup Token flavor 都请求完整 OAuth scope。Setup Token flavor 仍只改变运行模式;服务端 PKCE,30min;无代理不能生成 URL。 |
| 粘贴授权码 | `POST /api/panel/vms/:id/oauth/exchange-code` | 经槽 SOCKS5 换票,再执行 bootstrap 与 Grove PATCH,最后 `commitImportedOauth`。 |

换出的 access/refresh 只写入 credentials.json。`vm.json` / DB 只留 `has_access` / `has_refresh` / email / expiry / generation。Claude 面板默认选 Setup Token + Cookie。

Expand Down
2 changes: 1 addition & 1 deletion docs/PANEL_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
| POST | `/vms/reconcile-fingerprints` | 用官方 `~/.claude.json` 对齐指纹 |
| POST | `/probe` | 全量额度探测 |
| GET/POST | `/health-probe` | 读/跑官方 hello 健康探测缓存 |
| GET | `/usage` | 用量汇总(含缓存 token、官方价;账号行 `credential_mode` = `oauth` / `setup-token` / `apikey`) |
| GET | `/usage` | 用量汇总(含缓存 token、官方价;账号行 `credential_mode` = `oauth` / `setup-token` / `official-setup-token` / `apikey`) |
| GET | `/models` | 策略目录(不 hop worker) |
| GET | `/oauth` | 全槽脱敏 credential |

Expand Down
1 change: 1 addition & 0 deletions src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,7 @@ function enrichVm(v, accountQuota, active, extras = {}) {
oauth_source: v.oauth_source || null,
credential_mode: v.credential_mode || v.claude?.mode || 'oauth',
auth_scheme: v.auth_scheme || v.claude?.auth_scheme || null,
can_claude_reset: isCodex ? false : v.can_claude_reset === true,
has_refresh: !!(v.has_refresh || workerCred?.has_refresh),
has_session_key: !!v.has_session_key,
proxy: merged.proxy,
Expand Down
42 changes: 29 additions & 13 deletions src/lib/admin/panel-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
import {
canOfficialCc,
isApiKeyMode,
isAnySetupTokenMode,
isOfficialSetupTokenMode,
isSetupTokenMode,
looksLikeConsoleApiKey,
credentialModeOfVm,
Expand Down Expand Up @@ -1905,7 +1907,7 @@ export function createPanelHandler(ctx) {
const vm = getVm(cfg.paths.project, id)
if (!vm) return json(res, 404, { ok: false, error: { code: 'vm_not_found', message: 'vm not found' } })
const mode = credentialModeOfVm(vm)
if (!isSetupTokenMode(mode) && !isApiKeyMode(mode)) {
if (!isAnySetupTokenMode(mode) && !isApiKeyMode(mode)) {
return json(res, 400, {
ok: false,
error: {
Expand Down Expand Up @@ -2855,15 +2857,16 @@ export function createPanelHandler(ctx) {
scope: body.scope || (inference ? 'user:inference' : null),
source: body.source || (inference ? 'claude-setup-token' : 'access-token'),
}
if (inference) {
if (looksLikeOfficialSetupToken(accessToken) || (inference && !String(body.refresh_token || '').trim())) {
oauth.type = 'official-setup-token'
oauth.mode = 'official-setup-token'
oauth.refresh_token = ''
oauth.scope = 'user:inference'
oauth.source = body.source || 'claude-setup-token'
if (!oauth.expires_at) oauth.expires_at = Date.now() + 365 * 24 * 60 * 60 * 1000
} else if (inference) {
oauth.type = 'setup-token'
oauth.mode = 'setup-token'
if (!oauth.refresh_token) {
oauth.refresh_token = ''
if (!oauth.expires_at) {
oauth.expires_at = Date.now() + 365 * 24 * 60 * 60 * 1000
}
}
}
} else {
return json(res, 400, { ok: false, error: { message: 'sessionKey or access_token required' } })
Expand Down Expand Up @@ -3033,12 +3036,13 @@ export function createPanelHandler(ctx) {
proxyUrl: slotProxy.proxyUrl,
vmId: id,
})
if (
if (looksLikeOfficialSetupToken(code) || oauth.flavor === SETUP_TOKEN_FLAVOR) {
oauth.type = 'official-setup-token'
oauth.mode = 'official-setup-token'
} else if (
normalizeOauthFlavor(flavor) === 'setup_token' ||
oauth.flavor === 'setup_token' ||
oauth.flavor === 'setup-token' ||
oauth.flavor === SETUP_TOKEN_FLAVOR ||
looksLikeOfficialSetupToken(code)
oauth.flavor === 'setup-token'
) {
oauth.type = 'setup-token'
oauth.mode = 'setup-token'
Expand Down Expand Up @@ -3173,6 +3177,15 @@ export function createPanelHandler(ctx) {
error: { code: 'credential_kind_mismatch', message: 'Console API Key 不能转为 Setup Token' },
})
}
if (isOfficialSetupTokenMode(existing.claude?.mode) || isOfficialSetupTokenMode(cred?.type || cred?.mode)) {
return json(res, 400, {
ok: false,
error: {
code: 'credential_kind_mismatch',
message: '官方 Setup Token 只有 inference,不能转为完整 Setup Token',
},
})
}
if (isSetupTokenMode(existing.claude?.mode) || isSetupTokenMode(cred?.type || cred?.mode)) {
return json(
res,
Expand Down Expand Up @@ -3260,7 +3273,10 @@ export function createPanelHandler(ctx) {
error: { code: 'credential_mode_unsupported', message: 'Console API Key 不能刷新' },
})
}
if (isSetupTokenMode(vm?.claude?.mode) && !vm?.claude?.has_refresh) {
if (
isOfficialSetupTokenMode(vm?.claude?.mode) ||
(isSetupTokenMode(vm?.claude?.mode) && !vm?.claude?.has_refresh)
) {
return json(res, 400, {
ok: false,
error: { code: 'credential_mode_unsupported', message: '官方 Setup Token(无 refresh)不能刷新' },
Expand Down
12 changes: 9 additions & 3 deletions src/lib/admin/vm-test-chat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ export function testChatCredentialMode(vm = {}) {
flavor: vm.claude?.flavor,
source: vm.claude?.source,
})
if (inferred === 'setup-token' || inferred === 'apikey') return inferred
if (inferred === 'setup-token' || inferred === 'official-setup-token' || inferred === 'apikey') return inferred
return credentialModeOfVm(vm)
}

Expand Down Expand Up @@ -927,7 +927,13 @@ export async function runVmTestChat(opts = {}) {
})
}
const unofficial =
!codex && (cliHop || opts.unofficial === true || credMode === 'setup-token' || credMode === 'apikey')
!codex &&
(cliHop ||
opts.unofficial === true ||
credMode === 'setup-token' ||
credMode === 'official-setup-token' ||
credMode === 'apikey')

const cliLayout = !codex && cliHop ? resolveCliSystemLayout(vm, routing) : null
push(
'info',
Expand Down Expand Up @@ -1148,7 +1154,7 @@ export async function runVmTestChat(opts = {}) {
const text = result?.text || extractText(result?.body)
const usage = result?.usage || result?.body?.usage || null
let errObj = result?.ok ? null : extractError(result, { wrapHop: !codex })
if (errObj && result?.status === 401 && credMode === 'setup-token') {
if (errObj && result?.status === 401 && (credMode === 'setup-token' || credMode === 'official-setup-token')) {
errObj = {
...errObj,
code: 'setup_token_invalid',
Expand Down
4 changes: 2 additions & 2 deletions src/lib/oauth/claude-reset-credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { runSlotOauth } from '../transport/slot-oauth.mjs'
import { credentialModeOfVm, isApiKeyMode, isSetupTokenMode } from './credential-mode.mjs'
import { credentialModeOfVm, isApiKeyMode } from './credential-mode.mjs'
import { isCodexVm } from '../vm/vm-kind.mjs'
import { getVm } from '../vm/vm-registry.mjs'
import { atomicWriteJson, withVmLock } from '../vm/vm-file.mjs'
Expand Down Expand Up @@ -198,7 +198,7 @@ function scopeText(vm) {
function loadAccount(projectRoot, vmId) {
const vm = getVm(projectRoot, vmId)
if (!vm) return fail('vm_not_found', 'VM not found', 404)
if (isCodexVm(vm) || isApiKeyMode(credentialModeOfVm(vm)) || isSetupTokenMode(credentialModeOfVm(vm))) {
if (isCodexVm(vm) || isApiKeyMode(credentialModeOfVm(vm))) {
return fail('CLAUDE_RESET_OAUTH_REQUIRED', '只有 Claude 完整 OAuth 槽可以兑换原生限额重置', 400)
}
const scopes = scopeText(vm).split(/\s+/).filter(Boolean)
Expand Down
4 changes: 2 additions & 2 deletions src/lib/oauth/claude-setup-token.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ export function officialSetupTokenToOauth(token, extra = {}) {
throw fail('setup_token_invalid', '需要官方 claude setup-token 打印的一年期 sk-ant-oat01-…')
}
return {
type: 'setup-token',
mode: 'setup-token',
type: 'official-setup-token',
mode: 'official-setup-token',
access_token: access,
refresh_token: '',
expires_at: Date.now() + 365 * 24 * 60 * 60 * 1000,
Expand Down
67 changes: 60 additions & 7 deletions src/lib/oauth/credential-mode.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
/**
* Slot credential kinds: full OAuth, Setup Token runtime mode,
* and Anthropic Console API Key.
* Slot credential kinds: full OAuth, converted Setup Token (complete oat),
* official `claude setup-token` (inference-only), and Console API Key.
*/
import { flattenOauthIdentity } from './oauth-identity.mjs'
import { isCodexVm } from '../vm/vm-kind.mjs'

export const CREDENTIAL_OAUTH = 'oauth'
export const CREDENTIAL_SETUP_TOKEN = 'setup-token'
export const CREDENTIAL_OFFICIAL_SETUP_TOKEN = 'official-setup-token'
export const CREDENTIAL_APIKEY = 'apikey'

export function normalizeCredentialMode(raw) {
const s = String(raw || '')
.trim()
.toLowerCase()
.replace(/_/g, '-')
if (s === 'setup-token' || s === 'inference') return CREDENTIAL_SETUP_TOKEN
if (s === 'official-setup-token' || s === 'office-setup-token' || s === 'claude-setup-token') {
return CREDENTIAL_OFFICIAL_SETUP_TOKEN
}
if (s === 'setup-token') return CREDENTIAL_SETUP_TOKEN
if (s === 'inference') return CREDENTIAL_OFFICIAL_SETUP_TOKEN
if (s === 'apikey' || s === 'api-key' || s === 'console' || s === 'console-key') return CREDENTIAL_APIKEY
return CREDENTIAL_OAUTH
}
Expand All @@ -26,22 +32,30 @@ export function isSetupTokenMode(raw) {
return normalizeCredentialMode(raw) === CREDENTIAL_SETUP_TOKEN
}

export function isOfficialSetupTokenMode(raw) {
return normalizeCredentialMode(raw) === CREDENTIAL_OFFICIAL_SETUP_TOKEN
}

export function isAnySetupTokenMode(raw) {
return isSetupTokenMode(raw) || isOfficialSetupTokenMode(raw)
}

export function canOfficialCc(raw) {
return normalizeCredentialMode(raw) === CREDENTIAL_OAUTH
}

/** Official GET /api/oauth/usage|/profile. Setup-token oat is the same short-lived grant. */
/** Official GET /api/oauth/usage|/profile. Converted setup-token keeps the full grant. */
export function canOfficialUsage(raw) {
const mode = normalizeCredentialMode(raw)
return mode === CREDENTIAL_OAUTH || mode === CREDENTIAL_SETUP_TOKEN
}

export function canCountTokens(raw) {
return isSetupTokenMode(raw) || isApiKeyMode(raw)
return isAnySetupTokenMode(raw) || isApiKeyMode(raw)
}

export function canRefreshCredential(raw) {
return !isApiKeyMode(raw)
return !isApiKeyMode(raw) && !isOfficialSetupTokenMode(raw)
}

export function looksLikeConsoleApiKey(value) {
Expand All @@ -57,15 +71,37 @@ export function looksLikeOauthAccessToken(value) {
return /^sk-ant-oat01-/i.test(String(value || '').trim())
}

function scopeText(oauth = {}) {
if (Array.isArray(oauth.scopes) && oauth.scopes.length) return oauth.scopes.filter(Boolean).join(' ')
return String(oauth.scope || '')
}

/** Year-long official CLI token: inference only, no refresh. */
export function isOfficialSetupTokenGrant(oauth = {}) {
const labeled = normalizeCredentialMode(oauth.type || oauth.mode || oauth.credential_mode)
if (labeled === CREDENTIAL_OFFICIAL_SETUP_TOKEN) return true
const source = String(oauth.source || '')
const flavor = String(oauth.flavor || '').replace(/_/g, '-')
if (source === 'claude-setup-token' || flavor === 'claude-setup-token') return true
const refresh = String(oauth.refresh_token || oauth.refreshToken || '').trim()
if (refresh) return false
const scopes = scopeText(oauth)
.split(/\s+/)
.filter(Boolean)
.map((item) => (item === 'inference' ? 'user:inference' : item))
return scopes.length > 0 && scopes.every((item) => item === 'user:inference')
}

export function credentialModeFromOauth(oauth = {}) {
const typed = oauth.type || oauth.mode || oauth.credential_mode
const labeled = typed ? normalizeCredentialMode(typed) : ''
if (labeled === CREDENTIAL_APIKEY) return CREDENTIAL_APIKEY
if (looksLikeConsoleApiKey(oauth.api_key || oauth.apiKey || oauth.access_token || oauth.accessToken)) {
return CREDENTIAL_APIKEY
}
if (isOfficialSetupTokenGrant(oauth)) return CREDENTIAL_OFFICIAL_SETUP_TOKEN
if (labeled === CREDENTIAL_SETUP_TOKEN) return CREDENTIAL_SETUP_TOKEN
const scope = String(oauth.scope || (Array.isArray(oauth.scopes) ? oauth.scopes.join(' ') : ''))
const scope = scopeText(oauth)
if (oauth.flavor === 'setup_token' || oauth.flavor === 'setup-token') return CREDENTIAL_SETUP_TOKEN
if (/user:profile|user:sessions:claude_code/.test(scope)) return CREDENTIAL_OAUTH
if (scope && /user:inference/.test(scope)) return CREDENTIAL_SETUP_TOKEN
Expand All @@ -76,6 +112,23 @@ export function credentialModeOfVm(vm = {}) {
return normalizeCredentialMode(vm.credential_mode || vm.claude?.mode || vm.claude_mode)
}

/** Reset credits only. Converted setup-token with user:profile is a full OAuth grant. */
export function canClaudeResetCredits(vm = {}, { hasToken } = {}) {
if (isCodexVm(vm)) return false
const mode = credentialModeOfVm(vm)
if (isApiKeyMode(mode) || isOfficialSetupTokenMode(mode)) return false
const token =
hasToken != null
? !!hasToken
: !!(vm.has_token || vm.claude?.has_access || vm.claude?.access_token || vm.claude?.refresh_token)
if (!token) return false
const scope = [vm.claude?.scope, Array.isArray(vm.claude?.scopes) ? vm.claude.scopes.join(' ') : '', vm.oauth_scope]
.filter(Boolean)
.join(' ')
if (/(^|\s)user:profile(\s|$)/.test(scope)) return true
return !isSetupTokenMode(mode)
}

function fail(code, message) {
const err = new Error(message)
err.code = code
Expand Down
Loading
Loading