From 98eaeb6050eb00f0b5496a690f14cae0525e69d3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 17 Jun 2026 06:50:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20=E6=96=B0=E5=A2=9E=20OpenCode?= =?UTF-8?q?=20server=20provider=20=E4=B8=B2=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action.yaml | 22 ++++++++++++ app/config.js | 1 + app/llm.js | 87 +++++++++++++++++++++++++++++++++++++++++++++--- app/preflight.js | 35 +++++++++++++++++-- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/action.yaml b/action.yaml index 485776b..0d1e7cf 100644 --- a/action.yaml +++ b/action.yaml @@ -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 }} diff --git a/app/config.js b/app/config.js index ee25071..e2e0c6f 100644 --- a/app/config.js +++ b/app/config.js @@ -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 }; diff --git a/app/llm.js b/app/llm.js index e1a401a..a686053 100644 --- a/app/llm.js +++ b/app/llm.js @@ -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}`); } diff --git a/app/preflight.js b/app/preflight.js index 781bdb3..bcaa394 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -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}`);