From 5f77434953b41615b82829f4dfa711fa0fff9328 Mon Sep 17 00:00:00 2001 From: srant1 <86873477+srant1@users.noreply.github.com> Date: Tue, 12 May 2026 09:58:30 +0800 Subject: [PATCH] fix(llm): activate default model after OpenAI OAuth and gate provider checks by enabled OAuth token save now promotes the first available chat model when no usable default exists. Default-model resolution and provider availability checks require Provider.enabled=true alongside credentials, so disabled Providers no longer return stale defaults. /models/enabled drops the single-Provider hard-code so OpenAI OAuth and other enabled chat models surface in selectors. Docker exposes the 1455 PKCE callback via MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST (default 127.0.0.1) and a port mapping; deployment mode stays Host-driven. --- .env.example | 19 ++++++++++ docker-compose.yml | 5 +++ mateclaw-server/Dockerfile | 1 + .../mate/llm/oauth/OpenAIOAuthService.java | 26 ++++++++++--- .../mate/llm/service/ModelConfigService.java | 10 ++--- .../llm/service/ModelProviderService.java | 29 ++++++++++++-- .../llm/oauth/OpenAIOAuthServiceTest.java | 38 +++++++++++++++++++ 7 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceTest.java diff --git a/.env.example b/.env.example index dbe2a616..ce022f3a 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,25 @@ MATECLAW_BROWSER_CDP_URL= MATECLAW_BROWSER_CHROME_PATH= MATECLAW_BROWSER_CHANNEL= +# ==================== OpenAI OAuth(Docker,可选) ==================== +# +# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code, +# 不需要自定义 client secret。 +# +# 默认留空即可。后端会根据访问 Host 自动选择: +# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调) +# - IP / 域名 / 反向代理访问 → DEVICE_CODE(无缝远程授权) +# +# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成 +# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL,并让容器内 +# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到: +# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local +# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0 +# +# 强制模式调试时也可设为:local / device_code / manual_paste +MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE= +MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST= + # ── Maven 镜像(国内加速)───────────────────────────────────────── # 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。 # 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。 diff --git a/docker-compose.yml b/docker-compose.yml index ff2618ab..69408c32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,12 +92,17 @@ services: MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-} MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-} MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-} + # OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。 + # 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。 + MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-} + MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0} # Chromium needs a real /dev/shm. Docker defaults to 64MB which causes # SIGBUS / "Target page closed" errors under load. 2GB is the usual # recommendation for Playwright / headless chrome. shm_size: 2gb ports: - "18080:18088" # host:container — app listens on 18088 inside the container + - "1455:1455" volumes: - server_data:/app/data diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 16957831..05ecfb94 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -100,4 +100,5 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ COPY --from=builder /build/target/*.jar app.jar EXPOSE 18088 +EXPOSE 1455 ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"] diff --git a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java index b21c8119..06b5272b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java @@ -13,6 +13,7 @@ import org.springframework.web.client.RestClient; import vip.mate.exception.MateClawException; import vip.mate.llm.model.ModelProviderEntity; import vip.mate.llm.repository.ModelProviderMapper; +import vip.mate.llm.service.ModelProviderService; import java.io.OutputStream; import java.net.InetSocketAddress; @@ -73,9 +74,11 @@ public class OpenAIOAuthService { private static final String SCOPES = "openid profile email offline_access"; private static final String PROVIDER_ID = "openai-chatgpt"; private static final int CALLBACK_PORT = 1455; + private static final String DEFAULT_CALLBACK_BIND_HOST = "127.0.0.1"; private final ModelProviderMapper modelProviderMapper; private final ObjectMapper objectMapper; + private final ModelProviderService modelProviderService; private final RestClient restClient = RestClient.create(); /** state → code_verifier 缓存 */ @@ -240,15 +243,17 @@ public class OpenAIOAuthService { // Try to bind synchronously up front so callers can detect failure. HttpServer server; + String bindHost = resolveCallbackBindHost(); try { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", CALLBACK_PORT), 0); + server = HttpServer.create(new InetSocketAddress(bindHost, CALLBACK_PORT), 0); } catch (java.net.BindException e) { - log.warn("OAuth callback bind failed on port {} (in-use or restricted): {}", - CALLBACK_PORT, e.getMessage()); + log.warn("OAuth callback bind failed on {}:{} (in-use or restricted): {}", + bindHost, CALLBACK_PORT, e.getMessage()); pendingStates.remove(expectedState); return false; } catch (java.io.IOException e) { - log.warn("OAuth callback HttpServer.create IO error: {}", e.getMessage()); + log.warn("OAuth callback HttpServer.create IO error on {}:{}: {}", + bindHost, CALLBACK_PORT, e.getMessage()); pendingStates.remove(expectedState); return false; } @@ -311,7 +316,8 @@ public class OpenAIOAuthService { boundServer.start(); activeCallbackServer = boundServer; - log.info("OAuth 回调服务器已启动在 http://127.0.0.1:{}", CALLBACK_PORT); + log.info("OAuth 回调服务器已启动,监听 {}:{},浏览器回调地址 {}", + bindHost, CALLBACK_PORT, REDIRECT_URI); // 3 分钟超时自动关闭 CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> { @@ -490,6 +496,7 @@ public class OpenAIOAuthService { provider.setOauthAccountId(accountId); } modelProviderMapper.updateById(provider); + modelProviderService.activateFirstModelIfDefaultUnavailable(PROVIDER_ID); log.info("OpenAI OAuth token 已保存,expires_in={}s, accountId={}", expiresIn, accountId); } @@ -536,6 +543,15 @@ public class OpenAIOAuthService { } } + String resolveCallbackBindHost() { + String configured = System.getProperty("mateclaw.oauth.openai.callback-bind-host", + System.getenv("MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST")); + if (!StringUtils.hasText(configured)) { + return DEFAULT_CALLBACK_BIND_HOST; + } + return configured.trim(); + } + // ==================== PKCE 工具 ==================== private String generateCodeVerifier() { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index 66401c14..c278eb8d 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -43,10 +43,10 @@ public class ModelConfigService { public List listEnabledModels() { return modelConfigMapper.selectList(new LambdaQueryWrapper() .eq(ModelConfigEntity::getEnabled, true) - .eq(ModelConfigEntity::getProvider, "dashscope") // 仅 chat 类型(排除 embedding),NULL 兼容老数据 .and(w -> w.isNull(ModelConfigEntity::getModelType) .or().eq(ModelConfigEntity::getModelType, "chat")) + .orderByAsc(ModelConfigEntity::getProvider) .orderByDesc(ModelConfigEntity::getIsDefault) .orderByAsc(ModelConfigEntity::getName)); } @@ -131,7 +131,7 @@ public class ModelConfigService { .and(w -> w.isNull(ModelConfigEntity::getModelType) .or().eq(ModelConfigEntity::getModelType, "chat")) .last("LIMIT 1")); - if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) { + if (defaultMarked != null && isProviderEnabledAndConfigured(defaultMarked.getProvider())) { return defaultMarked; } @@ -144,7 +144,7 @@ public class ModelConfigService { .orderByDesc(ModelConfigEntity::getIsDefault) .orderByAsc(ModelConfigEntity::getName)); for (ModelConfigEntity candidate : candidates) { - if (isProviderConfigured(candidate.getProvider())) { + if (isProviderEnabledAndConfigured(candidate.getProvider())) { return candidate; } } @@ -165,12 +165,12 @@ public class ModelConfigService { * dependency. Falls back to {@code true} when the service is not yet available * (e.g., during early bootstrap) so we don't accidentally block startup. */ - private boolean isProviderConfigured(String providerId) { + private boolean isProviderEnabledAndConfigured(String providerId) { if (modelProviderService == null || providerId == null) { return true; } try { - return modelProviderService.isProviderConfigured(providerId); + return modelProviderService.isProviderEnabledAndConfigured(providerId); } catch (Exception e) { return true; // conservative: don't filter if lookup fails } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index be00295b..03ed03b1 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -238,11 +238,14 @@ public class ModelProviderService { public boolean isProviderAvailable(String providerId) { ModelProviderEntity provider = getProvider(providerId); - return isProviderConfigured(provider) && hasModels(providerId); + return isProviderEnabledAndConfigured(provider) && hasModels(providerId); } public String getProviderUnavailableReason(String providerId) { ModelProviderEntity provider = getProvider(providerId); + if (!Boolean.TRUE.equals(provider.getEnabled())) { + return "Provider 未启用"; + } if (!isProviderConfigured(provider)) { // Issue #81: emit a precise reason based on which row-level fields are // missing, rather than the previous protocol-blind heuristic. The new @@ -330,7 +333,7 @@ public class ModelProviderService { } private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) { - if (!isProviderConfigured(provider)) { + if (!isProviderEnabledAndConfigured(provider)) { return; } List providerModels = modelConfigService.listModelsByProvider(providerId); @@ -341,7 +344,7 @@ public class ModelProviderService { try { ModelConfigEntity currentDefault = modelConfigService.getDefaultModel(); ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider()); - if (!isProviderConfigured(defaultProvider)) { + if (!isProviderEnabledAndConfigured(defaultProvider)) { shouldAutoActivate = true; } } catch (MateClawException e) { @@ -353,6 +356,16 @@ public class ModelProviderService { } } + /** + * OAuth/device-code completion updates credentials outside the normal provider + * config endpoint. Reuse the same default-model promotion logic so a freshly + * connected OAuth provider is immediately selectable by chat. + */ + public void activateFirstModelIfDefaultUnavailable(String providerId) { + ModelProviderEntity provider = getProvider(providerId); + tryAutoActivateModel(providerId, provider); + } + private ModelProviderEntity getProvider(String providerId) { ModelProviderEntity provider = modelProviderMapper.selectById(providerId); if (provider == null) { @@ -565,6 +578,16 @@ public class ModelProviderService { && !"your-api-key-here".equalsIgnoreCase(normalized); } + public boolean isProviderEnabledAndConfigured(String providerId) { + return isProviderEnabledAndConfigured(getProvider(providerId)); + } + + private boolean isProviderEnabledAndConfigured(ModelProviderEntity provider) { + return provider != null + && Boolean.TRUE.equals(provider.getEnabled()) + && isProviderConfigured(provider); + } + public Map readProviderGenerateKwargs(ModelProviderEntity provider) { return readJson(provider != null ? provider.getGenerateKwargs() : null); } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceTest.java b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceTest.java new file mode 100644 index 00000000..30d4fa79 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceTest.java @@ -0,0 +1,38 @@ +package vip.mate.llm.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import vip.mate.llm.repository.ModelProviderMapper; +import vip.mate.llm.service.ModelProviderService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class OpenAIOAuthServiceTest { + + @AfterEach + void clearProperties() { + System.clearProperty("mateclaw.oauth.openai.callback-bind-host"); + } + + @Test + void resolveCallbackBindHostDefaultsToLoopback() { + OpenAIOAuthService service = service(); + + assertEquals("127.0.0.1", service.resolveCallbackBindHost()); + } + + @Test + void resolveCallbackBindHostUsesConfiguredProperty() { + System.setProperty("mateclaw.oauth.openai.callback-bind-host", "0.0.0.0"); + OpenAIOAuthService service = service(); + + assertEquals("0.0.0.0", service.resolveCallbackBindHost()); + } + + private OpenAIOAuthService service() { + return new OpenAIOAuthService(mock(ModelProviderMapper.class), new ObjectMapper(), + mock(ModelProviderService.class)); + } +}