Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51e9568ccf | ||
|
|
1118606e97 | ||
|
|
64af9e9e92 |
@@ -8,6 +8,8 @@ jobs:
|
|||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
env:
|
env:
|
||||||
VERSION: "0.0.0-beta.${{ gitea.run_number }}"
|
VERSION: "0.0.0-beta.${{ gitea.run_number }}"
|
||||||
|
outputs:
|
||||||
|
version: ${{ env.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Publishing Release
|
- name: Publishing Release
|
||||||
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
|
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
|
||||||
@@ -20,8 +22,8 @@ jobs:
|
|||||||
name: 2. TEST
|
name: 2. TEST
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
needs: [build]
|
needs: [build]
|
||||||
outputs:
|
env:
|
||||||
message: ${{ steps.composite-template.outputs.message }}
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Setup LLM CLI
|
- name: Setup LLM CLI
|
||||||
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
|
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
|
||||||
|
|||||||
+78
-6
@@ -1,4 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
import {
|
import {
|
||||||
GITEA_TOKEN,
|
GITEA_TOKEN,
|
||||||
GITEA_COMMENT_TOKEN,
|
GITEA_COMMENT_TOKEN,
|
||||||
@@ -11,6 +14,9 @@ import {
|
|||||||
import { verifyRemoteAccess } from './git.js';
|
import { verifyRemoteAccess } from './git.js';
|
||||||
import { step, line, ok, error, result } from './log.js';
|
import { step, line, ok, error, result } from './log.js';
|
||||||
|
|
||||||
|
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
|
||||||
|
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
|
||||||
|
|
||||||
const httpsAgent = getInsecureHttpsAgent();
|
const httpsAgent = getInsecureHttpsAgent();
|
||||||
/**
|
/**
|
||||||
* 組出 Gitea REST API v1 的完整網址。
|
* 組出 Gitea REST API v1 的完整網址。
|
||||||
@@ -95,22 +101,87 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。
|
||||||
|
*
|
||||||
|
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」,
|
||||||
|
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。
|
||||||
|
* 所有錯誤都被攔截並轉為回傳值,不會 throw。
|
||||||
|
*
|
||||||
|
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。
|
||||||
|
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
|
||||||
|
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
|
||||||
|
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
|
||||||
|
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
|
||||||
|
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
|
||||||
|
*/
|
||||||
|
export async function fetchCodexModels({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
authPath = join(os.homedir(), '.codex', 'auth.json'),
|
||||||
|
clientVersion = '0.142.5',
|
||||||
|
} = {}) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}): ${e.message}` };
|
||||||
|
}
|
||||||
|
const tokens = auth.tokens || {};
|
||||||
|
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${tokens.access_token}` };
|
||||||
|
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id;
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers });
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` };
|
||||||
|
}
|
||||||
|
if (resp.status === 401) {
|
||||||
|
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' };
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status})` };
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` };
|
||||||
|
}
|
||||||
|
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : [];
|
||||||
|
return { ok: true, slugs };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驗證 LLM(AI 助理 CLI)設定可用。
|
* 驗證 LLM(AI 助理 CLI)設定可用。
|
||||||
*
|
*
|
||||||
* 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI
|
* 確認目前環境可偵測到支援的 CLI 且已解析出 model;provider 為 codex 時,
|
||||||
* 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度。
|
* 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)。
|
||||||
|
* @param {object} [deps] - 可注入相依,供測試。
|
||||||
|
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。
|
||||||
* @returns {Promise<
|
* @returns {Promise<
|
||||||
* {ok: true, provider: string, command: string, model: string} |
|
* {ok: true, provider: string, command: string, model: string, models?: string[]} |
|
||||||
* {ok: false, provider?: string, error: string}
|
* {ok: false, provider?: string, command?: string, model?: string, error: string}
|
||||||
* >}
|
* >}
|
||||||
* 通過時含 provider、command 與 model;未設定 provider 的失敗分支不含 provider 欄位。
|
* 通過時含 provider、command、model(codex 另含 models 清單);未設定 provider 的失敗分支不含 provider。
|
||||||
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
||||||
*/
|
*/
|
||||||
export async function verifyLLM() {
|
export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) {
|
||||||
const { provider, command, model } = getLLMConfig();
|
const { provider, command, model } = getLLMConfig();
|
||||||
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
|
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
|
||||||
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
||||||
|
|
||||||
|
if (provider === 'codex') {
|
||||||
|
const models = await fetchCodexModelsFn();
|
||||||
|
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
|
||||||
|
if (!models.slugs.includes(model)) {
|
||||||
|
return { ok: false, provider, command, model, error: `模型 ${model} 不在 codex 可用清單: [${models.slugs.join(', ')}]` };
|
||||||
|
}
|
||||||
|
return { ok: true, provider, command, model, models: models.slugs };
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, provider, command, model };
|
return { ok: true, provider, command, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +245,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`);
|
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`);
|
||||||
|
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
|
||||||
|
|
||||||
result(true, '前置驗證通過');
|
result(true, '前置驗證通過');
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import axios from 'axios';
|
|||||||
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
|
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js';
|
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
|
||||||
|
|
||||||
const LLM_ENV_KEYS = [
|
const LLM_ENV_KEYS = [
|
||||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
|
||||||
@@ -130,18 +130,52 @@ describe('verifyLLM', () => {
|
|||||||
assert.match(result.error, /AI 助理 CLI/);
|
assert.match(result.error, /AI 助理 CLI/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes when a supported assistant CLI is detected', async () => {
|
it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
await installFakeCLI('codex');
|
await installFakeCLI('codex');
|
||||||
process.env.AI_ASSISTANT_CLI = 'codex';
|
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||||
process.env.MODEL = 'gpt-5-mini';
|
process.env.MODEL = 'gpt-5.4-mini';
|
||||||
|
|
||||||
const result = await verifyLLM();
|
const result = await verifyLLM({
|
||||||
|
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.provider, 'codex');
|
assert.equal(result.provider, 'codex');
|
||||||
assert.equal(result.command, 'codex');
|
assert.equal(result.command, 'codex');
|
||||||
assert.equal(result.model, 'gpt-5-mini');
|
assert.equal(result.model, 'gpt-5.4-mini');
|
||||||
|
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when codex auth is invalid (model list check reports 401)', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||||
|
process.env.MODEL = 'gpt-5.4-mini';
|
||||||
|
|
||||||
|
const result = await verifyLLM({
|
||||||
|
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.provider, 'codex');
|
||||||
|
assert.match(result.error, /HTTP 401/);
|
||||||
|
assert.match(result.error, /LLM_OAUTH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the configured model is not in the codex available list', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||||
|
process.env.MODEL = 'gpt-9-imaginary';
|
||||||
|
|
||||||
|
const result = await verifyLLM({
|
||||||
|
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /不在 codex 可用清單/);
|
||||||
|
assert.match(result.error, /gpt-5\.4-mini/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when a requested CLI is not installed', async () => {
|
it('fails when a requested CLI is not installed', async () => {
|
||||||
@@ -157,6 +191,59 @@ describe('verifyLLM', () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchCodexModels', () => {
|
||||||
|
async function writeAuth(json) {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
|
||||||
|
const authPath = join(tempDir, 'auth.json');
|
||||||
|
await writeFile(authPath, JSON.stringify(json));
|
||||||
|
return authPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the model slugs on HTTP 200', async () => {
|
||||||
|
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
|
||||||
|
let capturedUrl, capturedHeaders;
|
||||||
|
const result = await fetchCodexModels({
|
||||||
|
authPath,
|
||||||
|
fetchImpl: async (url, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedHeaders = opts.headers;
|
||||||
|
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] });
|
||||||
|
assert.match(capturedUrl, /client_version=/);
|
||||||
|
assert.equal(capturedHeaders['Authorization'], 'Bearer tok');
|
||||||
|
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an auth failure on HTTP 401', async () => {
|
||||||
|
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } });
|
||||||
|
const result = await fetchCodexModels({
|
||||||
|
authPath,
|
||||||
|
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /HTTP 401/);
|
||||||
|
assert.match(result.error, /LLM_OAUTH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the auth file cannot be read', async () => {
|
||||||
|
const result = await fetchCodexModels({
|
||||||
|
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'),
|
||||||
|
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }),
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /無法讀取 codex 認證檔/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the auth file lacks an access_token', async () => {
|
||||||
|
const authPath = await writeAuth({ tokens: {} });
|
||||||
|
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /缺少 tokens\.access_token/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('runPreflight', () => {
|
describe('runPreflight', () => {
|
||||||
function makeDeps(overrides = {}) {
|
function makeDeps(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user