feat(opencode): 新增 OpenCode server provider 串接 #16

Merged
admin merged 5 commits from develop into master 2026-06-17 07:00:28 +00:00
4 changed files with 138 additions and 7 deletions
Showing only changes of commit 98eaeb6050 - Show all commits
+22
View File
@@ -79,6 +79,23 @@ inputs:
description: 'Amazon Q Base URL'
required: false
# OpenCode Server
OPENCODE_BASE_URL:
description: 'OpenCode server Base URL'
required: false
OPENCODE_MODEL:
description: 'OpenCode model id'
required: false
OPENCODE_PROVIDER:
description: 'OpenCode server provider id'
required: false
OPENCODE_SERVER_USERNAME:
description: 'OpenCode server Basic Auth username'
required: false
OPENCODE_SERVER_PASSWORD:
description: 'OpenCode server Basic Auth password'
required: false
runs:
using: 'docker'
image: 'Dockerfile'
@@ -107,3 +124,8 @@ runs:
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }}
OPENCODE_BASE_URL: ${{ inputs.OPENCODE_BASE_URL }}
OPENCODE_MODEL: ${{ inputs.OPENCODE_MODEL }}
OPENCODE_PROVIDER: ${{ inputs.OPENCODE_PROVIDER }}
OPENCODE_SERVER_USERNAME: ${{ inputs.OPENCODE_SERVER_USERNAME }}
OPENCODE_SERVER_PASSWORD: ${{ inputs.OPENCODE_SERVER_PASSWORD }}
+1
View File
@@ -24,6 +24,7 @@ export function getLLMConfig() {
['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
['opencode', ['opencode'], process.env.OPENCODE_BASE_URL, process.env.OPENCODE_MODEL || 'gemini-2.5-flash'],
];
for (const [provider, apiKeys, baseURL, model] of checks) {
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
+83 -4
View File
@@ -2,6 +2,81 @@ import axios from 'axios';
import { getLLMConfig } from './config.js';
import { line, error } from './log.js';
function isOpenAIGpt55(provider, model) {
return provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
}
function chatEndpoint(baseURL, provider, model) {
const base = baseURL.replace(/\/$/, '');
return isOpenAIGpt55(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
}
function chatPayload(provider, model, systemPrompt, userContent) {
if (isOpenAIGpt55(provider, model)) {
return { model, instructions: systemPrompt, input: userContent, temperature: 0.2 };
}
return { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 };
}
function extractContent(provider, model, data) {
if (!isOpenAIGpt55(provider, model)) return data.choices[0].message.content;
if (typeof data.output_text === 'string') return data.output_text;
const parts = data.output?.flatMap(item => item.content || []) || [];
const text = parts
.map(part => {
if (typeof part.text === 'string') return part.text;
if (typeof part.content === 'string') return part.content;
return '';
})
.filter(Boolean)
.join('');
if (text) return text;
return data.choices?.[0]?.message?.content || '';
}
function opencodeModelConfig(model) {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
}
function applyOpenCodeAuth(headers) {
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (!password) return;
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
function extractOpenCodeContent(data) {
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
return parts
.map(part => part.text || part.content || '')
.filter(Boolean)
.join('');
}
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
const base = baseURL.replace(/\/$/, '');
const { providerID, modelID } = opencodeModelConfig(model);
const session = await axios.post(
`${base}/session`,
{ title: 'AI Code Review', model: { providerID, id: modelID } },
{ headers }
);
const sessionID = session.data.id || session.data.data?.id;
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
const resp = await axios.post(
`${base}/session/${sessionID}/message`,
{
model: { providerID, modelID },
system: systemPrompt,
parts: [{ type: 'text', text: userContent }],
},
{ headers }
);
return extractOpenCodeContent(resp.data);
}
export async function chat(systemPrompt, userContent) {
const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) throw new Error('未設定任何 LLM API Key');
@@ -13,14 +88,18 @@ export async function chat(systemPrompt, userContent) {
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
for (let i = 0; i < shuffled.length; i++) {
if (provider !== 'ollama') headers['Authorization'] = `Bearer ${shuffled[i]}`;
if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`;
try {
if (provider === 'opencode') {
applyOpenCodeAuth(headers);
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
}
const resp = await axios.post(
`${baseURL.replace(/\/$/, '')}/chat/completions`,
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 },
chatEndpoint(baseURL, provider, model),
chatPayload(provider, model, systemPrompt, userContent),
{ headers }
);
return resp.data.choices[0].message.content;
return extractContent(provider, model, resp.data);
} catch (e) {
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
}
+32 -3
View File
@@ -15,6 +15,17 @@ import { step, line, ok, error } from './log.js';
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
const usesResponsesApi = (provider, model) => provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
const opencodeModelConfig = (model) => {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
};
const applyOpenCodeAuth = (headers) => {
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (!password) return;
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
};
function giteaErr(e) {
const status = e.response?.status;
@@ -63,6 +74,7 @@ export async function verifyLLM() {
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
const base = baseURL.replace(/\/$/, '');
const headers = { 'Content-Type': 'application/json' };
if (provider === 'ollama') {
try {
@@ -73,14 +85,31 @@ export async function verifyLLM() {
}
}
const headers = { 'Content-Type': 'application/json' };
if (provider === 'opencode') {
const { providerID, modelID } = opencodeModelConfig(model);
applyOpenCodeAuth(headers);
try {
await axios.get(`${base}/global/health`, { headers, timeout: 30000 });
const providers = await axios.get(`${base}/config/providers`, { headers, timeout: 30000 });
const configuredProvider = providers.data.providers?.find(p => p.id === providerID);
if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` };
if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` };
return { ok: true, provider };
} catch (e) {
return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` };
}
}
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
const endpoint = usesResponsesApi(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
const payload = usesResponsesApi(provider, model)
? { model, input: 'ping', max_output_tokens: 1, temperature: 0 }
: { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
for (let i = 0; i < apiKeys.length; i++) {
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
try {
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 });
await axios.post(endpoint, payload, { headers, timeout: 30000 });
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
} catch (e) {
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);