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.
This commit is contained in:
srant1 2026-05-12 09:58:30 +08:00 committed by GitHub
parent 27f614d48a
commit 5f77434953
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 115 additions and 13 deletions

View File

@ -50,6 +50,25 @@ MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code
# 不需要自定义 client secret。
#
# 默认留空即可。后端会根据访问 Host 自动选择:
# - localhost / 127.0.0.1 / ::1 → LOCALPKCE 回调)
# - 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 的顺序。

View File

@ -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 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 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

View File

@ -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"]

View File

@ -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() {

View File

@ -43,10 +43,10 @@ public class ModelConfigService {
public List<ModelConfigEntity> listEnabledModels() {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getEnabled, true)
.eq(ModelConfigEntity::getProvider, "dashscope")
// chat 类型排除 embeddingNULL 兼容老数据
.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
}

View File

@ -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<ModelConfigEntity> 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<String, Object> readProviderGenerateKwargs(ModelProviderEntity provider) {
return readJson(provider != null ? provider.getGenerateKwargs() : null);
}

View File

@ -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));
}
}