Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2635,6 +2635,8 @@
"acpClaudeOAuthCodeRequired": "Paste the Claude authorization code.",
"acpClaudeOAuthExchange": "Save Token",
"acpClaudeOAuthExchangeFailed": "Failed to save Claude token",
"acpCredentialTestHint": "Verify the saved API key against the provider endpoint.",
"acpCredentialTestOk": "Credentials verified · {latency}ms",
"acpSelfModeHint": "Uses the Agent you've already configured inside the workspace runtime (e.g. an existing login).",
"acpHermesSelfModeHint": "Uses the Hermes profile already configured in this workspace. Use API Key setup for Memoh-managed bot credentials.",
"acpHermesSelfModeConfirm": "Use self-managed Hermes",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2635,6 +2635,8 @@
"acpClaudeOAuthCodeRequired": "请粘贴 Claude 授权码。",
"acpClaudeOAuthExchange": "保存 Token",
"acpClaudeOAuthExchangeFailed": "保存 Claude Token 失败",
"acpCredentialTestHint": "验证已保存的 API Key 能否连通上游端点。",
"acpCredentialTestOk": "验证通过 · {latency}ms",
"acpSelfModeHint": "该模式直接使用工作区运行环境里你已配置好的 Agent(例如已登录的凭据)。",
"acpHermesSelfModeHint": "该模式直接使用当前工作区里已配置好的 Hermes Profile。如需由 Memoh 管理 Bot 凭据,请使用 API Key 配置。",
"acpHermesSelfModeConfirm": "使用自行配置的 Hermes",
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/pages/bots/components/settings-acp-detail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@
@field-commit="commitForm"
/>

<div
v-if="credentialTestVisible"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p
class="min-w-0 flex-1 break-words text-sm"
:class="credentialTestError ? 'text-destructive' : 'text-muted-foreground'"
>
{{ credentialTestText }}
</p>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
:disabled="!credentialTestReady"
:loading="testingCredentials"
@click="runCredentialTest"
>
{{ $t('provider.testConnection') }}
</Button>
</div>

<p
v-else-if="agent.setup_mode === 'self'"
class="break-words text-sm text-muted-foreground"
Expand Down Expand Up @@ -234,7 +257,9 @@ import {
} from '@felinic/ui'
import { KeyRound } from 'lucide-vue-next'
import {
postBotsByBotIdAcpAgentsByAgentIdCredentialsTest,
type AcpprofilePublicProfile,
type ProvidersTestResponse,
} from '@memohai/sdk'
import { useACPOAuth } from '@/composables/useACPOAuth'
import { useAcpSetupModeItems } from '@/composables/useAcpSetupModeItems'
Expand All @@ -252,6 +277,8 @@ import {
type ACPForm,
} from '@/utils/acp'
import { filterSettingsVisibleManagedFields } from '@/utils/acp/setup-fields'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatProbeError } from '@/utils/probe-error'
import AcpManagedFields from './acp-managed-fields.vue'

const props = defineProps<{
Expand Down Expand Up @@ -309,6 +336,47 @@ const visibleManagedFields = computed(() =>
filterSettingsVisibleManagedFields(props.profile, agent.value.managed, agent.value.setup_mode),
)

const testingCredentials = ref(false)
const credentialTestResult = ref<ProvidersTestResponse | null>(null)
const credentialTestError = ref('')
const credentialTestVisible = computed(() =>
(isCodex.value || isClaude.value) && agent.value.setup_mode === 'api_key',
)
const credentialTestReady = computed(() => !!agent.value.managed.api_key?.trim())
const credentialTestText = computed(() => {
if (credentialTestError.value) return credentialTestError.value
if (credentialTestResult.value?.status === 'ok') {
return t('bots.settings.acpCredentialTestOk', { latency: credentialTestResult.value.latency_ms ?? 0 })
}
return t('bots.settings.acpCredentialTestHint')
})

async function runCredentialTest() {
if (!credentialTestReady.value || testingCredentials.value) return
testingCredentials.value = true
credentialTestResult.value = null
credentialTestError.value = ''
try {
const { data } = await postBotsByBotIdAcpAgentsByAgentIdCredentialsTest({
path: { bot_id: props.botId, agent_id: props.profile.id },
throwOnError: true,
})
credentialTestResult.value = data ?? null
if (data?.status !== 'ok') {
credentialTestError.value = formatProbeError(data?.message, t('provider.unreachable'))
}
} catch (err: unknown) {
credentialTestError.value = resolveApiErrorMessage(err, t('provider.testFailed'))
} finally {
testingCredentials.value = false
}
}

watch([() => props.botId, () => props.profile.id, () => agent.value.setup_mode], () => {
credentialTestResult.value = null
credentialTestError.value = ''
})

// 只有走托管 OAuth 的两个 agent、且当前就在 OAuth 模式时,账号卡片才有存在意义。
const oauthSectionVisible = computed(() =>
(isCodex.value || isClaude.value) && agent.value.setup_mode === 'oauth',
Expand Down
21 changes: 2 additions & 19 deletions apps/web/src/pages/providers/components/provider-form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ import { useI18n } from 'vue-i18n'
import { ConfirmPopover, DeviceCodePanel, SettingsRow, SettingsSection, toast } from '@felinic/ui'
import { useProviderModelCatalog } from '@/composables/useProviderModelCatalog'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatProbeError } from '@/utils/probe-error'
import { useAutosaveQueue, type AutosaveJob } from '@/composables/use-autosave-queue'

const { t } = useI18n()
Expand Down Expand Up @@ -432,26 +433,8 @@ const cacheDescription = computed(() =>
: t('provider.promptCache.description'),
)

function truncateError(text: string): string {
const max = 220
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
}

// The probe detail can embed the raw upstream response inside `[body: …]`. When
// a Base URL points at a website instead of an API the body is a full HTML page;
// its visible text is page prose ("Example Domain … Learn more"), never an
// actionable API error — and stripping tags leaves dead, unclickable text. Drop
// HTML bodies entirely and keep only the status head; non-HTML bodies (real
// JSON API errors) are still shown.
function formatTestError(raw: string | undefined): string {
const text = (raw ?? '').trim()
if (!text) return t('provider.unreachable')
const bodyStart = text.indexOf('[body:')
if (bodyStart === -1) return truncateError(text)
const head = text.slice(0, bodyStart).trim()
const body = text.slice(bodyStart + '[body:'.length).replace(/\]\s*$/, '').trim()
if (/<!doctype|<\/?[a-z][^>]*>/i.test(body)) return truncateError(head)
return truncateError(body ? `${head} · ${body}` : head)
return formatProbeError(raw, t('provider.unreachable'))
}

async function runTest() {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/utils/probe-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { formatProbeError } from './probe-error'

describe('formatProbeError', () => {
it('returns the fallback for empty input', () => {
expect(formatProbeError(undefined, 'unreachable')).toBe('unreachable')
expect(formatProbeError(' ', 'unreachable')).toBe('unreachable')
})

it('passes short plain messages through', () => {
expect(formatProbeError('authentication failed (HTTP 401)', 'x')).toBe('authentication failed (HTTP 401)')
})

it('truncates long messages with an ellipsis', () => {
const long = 'a'.repeat(300)
const out = formatProbeError(long, 'x')
expect(out).toHaveLength(221)
expect(out.endsWith('…')).toBe(true)
})

it('drops html from an embedded [body: …] payload', () => {
const raw = 'service error (404): [body: <!doctype html><html><head><style>p{color:red}</style></head><body><p>Not Found</p></body></html>]'
expect(formatProbeError(raw, 'x')).toBe('service error (404):')
})

it('keeps only the head when the body collapses to nothing', () => {
const raw = 'service error (502): [body: <html><script>boom()</script></html>]'
expect(formatProbeError(raw, 'x')).toBe('service error (502):')
})
})
19 changes: 19 additions & 0 deletions apps/web/src/utils/probe-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
function truncateProbeError(text: string): string {
const max = 220
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
}

// The probe detail can embed the raw upstream response inside `[body: …]`. When
// a Base URL points at a website instead of an API the body is a full HTML page;
// its visible text is page prose, not an actionable API error. Drop HTML bodies
// entirely and keep only the status head; real JSON API errors remain visible.
export function formatProbeError(raw: string | undefined, fallback: string): string {
const text = (raw ?? '').trim()
if (!text) return fallback
const bodyStart = text.indexOf('[body:')
if (bodyStart === -1) return truncateProbeError(text)
const head = text.slice(0, bodyStart).trim()
const body = text.slice(bodyStart + '[body:'.length).replace(/\]\s*$/, '').trim()
if (/<!doctype|<\/?[a-z][^>]*>/i.test(body)) return truncateProbeError(head)
return truncateProbeError(body ? `${head} · ${body}` : head)
}
1 change: 1 addition & 0 deletions cmd/agent/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func commonOptions() fx.Option {
provideServerHandler(provideProviderOAuthHandler),
provideServerHandler(provideACPCodexOAuthServerHandler),
provideServerHandler(provideACPClaudeCodeOAuthServerHandler),
provideServerHandler(handlers.NewACPCredentialsHandler),
provideServerHandler(handlers.NewFetchProvidersHandler),
provideServerHandler(handlers.NewSearchProvidersHandler),
provideServerHandler(handlers.NewModelsHandler),
Expand Down
65 changes: 65 additions & 0 deletions internal/agent/runtime/acp/profile/credential_probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package profile

import (
"errors"
"strings"
)

const (
codexProbeClientType = "openai-responses"
codexProbeBaseURL = "https://api.openai.com/v1"
claudeCodeProbeClientType = "anthropic-messages"
claudeCodeProbeBaseURL = "https://api.anthropic.com/v1"
)

var (
ErrCredentialProbeUnsupported = errors.New("acp agent does not support api key credential testing")
ErrCredentialProbeNotAPIKeyMode = errors.New("acp agent is not configured with api key setup")
ErrCredentialProbeAPIKeyMissing = errors.New("acp agent api key is not configured")
)

// CredentialProbeTarget describes the provider endpoint that validates an ACP
// agent's managed API key without starting the agent process.
type CredentialProbeTarget struct {
ClientType string
BaseURL string
APIKey string //nolint:gosec // runtime credential material used to construct SDK providers
}

// APIKeyProbeTarget resolves the endpoint probed to validate the managed API
// key of setup, mirroring how each runtime consumes base_url: Codex writes it
// into config.toml verbatim including /v1 (wire_api "responses"), while Claude
// Code's ANTHROPIC_BASE_URL excludes /v1 (the agent appends /v1/... itself),
// so the probe appends /v1 to reach the same API surface.
func APIKeyProbeTarget(setup AgentSetup) (CredentialProbeTarget, error) {
if normalizeSetupMode(setup.Mode, setup.Managed) != setupModeAPIKey {
return CredentialProbeTarget{}, ErrCredentialProbeNotAPIKeyMode
}
baseURL := strings.TrimRight(strings.TrimSpace(setup.Managed["base_url"]), "/")
var target CredentialProbeTarget
switch NormalizeAgentID(setup.AgentID) {
case AgentCodexID:
target = CredentialProbeTarget{
ClientType: codexProbeClientType,
BaseURL: codexProbeBaseURL,
}
if baseURL != "" {
target.BaseURL = baseURL
}
case AgentClaudeCodeID:
target = CredentialProbeTarget{
ClientType: claudeCodeProbeClientType,
BaseURL: claudeCodeProbeBaseURL,
}
if baseURL != "" {
target.BaseURL = baseURL + "/v1"
}
default:
return CredentialProbeTarget{}, ErrCredentialProbeUnsupported
}
target.APIKey = strings.TrimSpace(setup.Managed["api_key"])
if target.APIKey == "" {
return CredentialProbeTarget{}, ErrCredentialProbeAPIKeyMissing
}
return target, nil
}
Loading
Loading