mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
27f614d48a
commit
5f77434953
19
.env.example
19
.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 的顺序。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 类型(排除 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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user