Pointing OPENAI_BASE_URL at a local llama-server or Ollama sends every gpt-* call there instead of to OpenAI, and litellm reports that as a bare "Connection Error" — which reads like a network problem even though the host can reach api.openai.com fine. Deploying a .env that still carries the local endpoint reproduces it on the server, with nothing in the message naming the culprit. Add OPENAI_BASE_URL and OPENAI_API_BASE to the settings allowlist so the endpoint can be corrected without editing .env and recreating the container. Both are listed because litellm reads OPENAI_API_BASE while the OpenAI SDK reads OPENAI_BASE_URL, so fixing one can leave the other in effect. Neither is a secret, so the panel shows the current value. No cache invalidation is needed alongside this: litellm keys its client cache on the endpoint, so a changed value takes effect on the next call. Document the trap where it will be read — a commented template in .env.example and a note in the Lightsail deploy guide about carrying a local .env over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
6.7 KiB
Vue
196 lines
6.7 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, watch } from 'vue'
|
||
import { useSettingsStore } from '../stores/settings'
|
||
|
||
const store = useSettingsStore()
|
||
|
||
const passwordInput = ref('')
|
||
const drafts = reactive<Record<string, string>>({})
|
||
|
||
const presetDrafts = reactive<Record<number, { label: string; value: string }>>({})
|
||
const newLabel = ref('')
|
||
const newValue = ref('')
|
||
|
||
watch(
|
||
() => store.modelPresets,
|
||
(presets) => {
|
||
for (const key of Object.keys(presetDrafts)) delete presetDrafts[Number(key)]
|
||
for (const p of presets) presetDrafts[p.id] = { label: p.label, value: p.value }
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
async function saveModelPreset(id: number) {
|
||
const draft = presetDrafts[id]
|
||
if (!draft || !draft.label || !draft.value) return
|
||
await store.editModelPreset(id, draft.label, draft.value)
|
||
}
|
||
|
||
async function addModelPreset() {
|
||
if (!newLabel.value || !newValue.value) return
|
||
const ok = await store.addModelPreset(newLabel.value, newValue.value)
|
||
if (ok) {
|
||
newLabel.value = ''
|
||
newValue.value = ''
|
||
}
|
||
}
|
||
|
||
const KEY_LABELS: Record<string, string> = {
|
||
OPENAI_API_KEY: 'OpenAI APIキー',
|
||
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
|
||
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
|
||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||
AWS_SESSION_TOKEN: 'AWS Session Token',
|
||
AWS_BEARER_TOKEN_BEDROCK: 'AWS Bedrock Bearer Token',
|
||
AWS_REGION_NAME: 'AWS Region',
|
||
OLLAMA_API_BASE: 'Ollama API Base URL',
|
||
TAVILY_API_KEY: 'Tavily APIキー',
|
||
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
|
||
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
|
||
}
|
||
|
||
async function submitPassword() {
|
||
const ok = await store.unlock(passwordInput.value)
|
||
if (ok) passwordInput.value = ''
|
||
}
|
||
|
||
async function saveAll() {
|
||
const values: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(drafts)) {
|
||
if (value) values[key] = value
|
||
}
|
||
if (Object.keys(values).length === 0) return
|
||
const ok = await store.save(values)
|
||
if (ok) {
|
||
for (const key of Object.keys(drafts)) drafts[key] = ''
|
||
}
|
||
}
|
||
|
||
async function clearOne(key: string) {
|
||
await store.clear(key)
|
||
}
|
||
|
||
onMounted(() => {
|
||
store.tryAutoUnlock()
|
||
store.loadModelPresets()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="settings-panel">
|
||
<div class="toolbar">
|
||
<span class="title">API接続設定</span>
|
||
<button v-if="store.unlocked" class="lock-btn" @click="store.lock()">ロック</button>
|
||
</div>
|
||
|
||
<form v-if="!store.unlocked" class="login" @submit.prevent="submitPassword">
|
||
<p class="hint">APIキーを表示・変更するにはパスワードが必要です。</p>
|
||
<input
|
||
v-model="passwordInput"
|
||
type="password"
|
||
placeholder="パスワード"
|
||
autocomplete="current-password"
|
||
/>
|
||
<button type="submit" :disabled="store.loading || !passwordInput">
|
||
{{ store.loading ? '確認中...' : 'ロック解除' }}
|
||
</button>
|
||
<p v-if="store.error" class="error">{{ store.error }}</p>
|
||
</form>
|
||
|
||
<div v-else class="entries">
|
||
<p v-if="store.error" class="error">{{ store.error }}</p>
|
||
<div v-for="entry in store.entries" :key="entry.key" class="entry">
|
||
<label>{{ KEY_LABELS[entry.key] ?? entry.key }}</label>
|
||
<div class="row">
|
||
<input
|
||
v-model="drafts[entry.key]"
|
||
:type="entry.secret ? 'password' : 'text'"
|
||
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
|
||
autocomplete="off"
|
||
/>
|
||
<button
|
||
v-if="entry.overridden"
|
||
class="clear-btn"
|
||
title="GUIで設定した値をクリアして.envの値に戻す"
|
||
@click="clearOne(entry.key)"
|
||
>
|
||
クリア
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button class="save-btn" :disabled="store.loading" @click="saveAll">
|
||
{{ store.loading ? '保存中...' : '保存' }}
|
||
</button>
|
||
|
||
<hr />
|
||
|
||
<h3 class="section-title">モデル管理</h3>
|
||
<div v-for="p in store.modelPresets" :key="p.id" class="preset-row">
|
||
<input v-model="presetDrafts[p.id].label" placeholder="表示名" />
|
||
<input v-model="presetDrafts[p.id].value" placeholder="モデル文字列" />
|
||
<button class="mini-btn" @click="saveModelPreset(p.id)">保存</button>
|
||
<button class="mini-btn danger" @click="store.removeModelPreset(p.id)">削除</button>
|
||
</div>
|
||
|
||
<div class="preset-row new-preset">
|
||
<input v-model="newLabel" placeholder="表示名(例: GPT-5.5)" />
|
||
<input v-model="newValue" placeholder="モデル文字列(例: gpt-5.5)" />
|
||
<button class="mini-btn" @click="addModelPreset">追加</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
background: #111;
|
||
}
|
||
.toolbar {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 12px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
|
||
}
|
||
.title { font-size: 13px; font-weight: 600; color: #aaa; }
|
||
.lock-btn {
|
||
background: none; border: 1px solid #444; color: #aaa;
|
||
border-radius: 5px; padding: 2px 10px; font-size: 12px; cursor: pointer;
|
||
}
|
||
.login { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||
.hint { font-size: 12px; color: #888; }
|
||
.error { font-size: 12px; color: #f87171; }
|
||
input {
|
||
background: #2a2a2a; color: #eee; border: 1px solid #444;
|
||
border-radius: 6px; padding: 6px 10px; font-size: 13px; width: 100%;
|
||
}
|
||
button {
|
||
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
|
||
border-radius: 6px; padding: 6px 10px; font-size: 13px; cursor: pointer;
|
||
}
|
||
button:disabled { opacity: 0.5; cursor: default; }
|
||
.entries { padding: 12px; display: flex; flex-direction: column; gap: 14px; }
|
||
.entry { display: flex; flex-direction: column; gap: 4px; }
|
||
.entry label { font-size: 12px; color: #888; }
|
||
.row { display: flex; gap: 6px; }
|
||
.row input { flex: 1; }
|
||
.clear-btn {
|
||
background: none; border: 1px solid #444; color: #aaa;
|
||
font-size: 12px; padding: 4px 8px; white-space: nowrap;
|
||
}
|
||
.save-btn { align-self: flex-start; padding: 6px 18px; }
|
||
hr { border: none; border-top: 1px solid #2a2a2a; margin: 4px 0; }
|
||
.section-title { font-size: 12px; color: #888; text-transform: uppercase; }
|
||
.preset-row { display: flex; gap: 6px; align-items: center; }
|
||
.preset-row input { flex: 1; min-width: 0; }
|
||
.mini-btn {
|
||
background: none; border: 1px solid #444; color: #aaa;
|
||
font-size: 12px; padding: 6px 10px; white-space: nowrap;
|
||
}
|
||
.mini-btn.danger { border-color: #7f1d1d; color: #f87171; }
|
||
.new-preset { padding-top: 4px; }
|
||
</style>
|